From d10179f9a3588523419b173be14f6cc2ae724d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:01:16 +0200 Subject: [PATCH 01/40] feat(integrations): resolve one health state per connection Fold the ledger state, liveness verdict, plan and scope gaps and the provider reauth flag into ok / limited / degraded / action_required with a reason code, the one action, the unavailable types and redacted diagnostics. Keep the last ten sync attempts per connection for those diagnostics, publish the state on the integrations envelope, every per-provider status route and the Apple Health config read, and page the operator once per failure streak instead of once a day. --- .../migration.sql | 40 ++ prisma/schema.prisma | 47 ++ src/app/api/fitbit/status/route.ts | 5 + src/app/api/google-health/status/route.ts | 5 + src/app/api/integrations/healthkit/route.ts | 8 +- src/app/api/integrations/status/route.ts | 34 ++ src/app/api/nightscout/status/route.ts | 5 + src/app/api/oura/status/route.ts | 5 + src/app/api/polar/status/route.ts | 5 + src/app/api/strava/status/route.ts | 5 + src/app/api/whoop/status/route.ts | 5 + src/app/api/withings/status/route.ts | 5 + src/lib/integrations/__tests__/status.test.ts | 20 +- .../integrations/connection-health-load.ts | 436 ++++++++++++++++++ src/lib/integrations/connection-health.ts | 390 ++++++++++++++++ src/lib/integrations/status.ts | 150 +++++- src/lib/integrations/watchdog-classify.ts | 213 +++++++++ 17 files changed, 1351 insertions(+), 27 deletions(-) create mode 100644 prisma/migrations/0400_integration_health_watchdog/migration.sql create mode 100644 src/lib/integrations/connection-health-load.ts create mode 100644 src/lib/integrations/connection-health.ts create mode 100644 src/lib/integrations/watchdog-classify.ts diff --git a/prisma/migrations/0400_integration_health_watchdog/migration.sql b/prisma/migrations/0400_integration_health_watchdog/migration.sql new file mode 100644 index 000000000..6f13cefe8 --- /dev/null +++ b/prisma/migrations/0400_integration_health_watchdog/migration.sql @@ -0,0 +1,40 @@ +-- v1.42.4 — one health state per connection, and a watchdog behind it. +-- +-- `integration_statuses.recent_calls` keeps the last ten sync attempts per +-- connection as `{ endpoint, status, at, errorCode }`: the leg name, the +-- outcome, the instant and a short error code. No message, token or value is +-- stored in it; it is the source of the redacted diagnostics a user can copy +-- into a bug report. +-- +-- `integration_watches` holds what the integration watchdog observed per +-- connection: whether a source that normally delivers has gone silent, and +-- the one operator incident open for it, so an alert is sent once per +-- incident and a recovery note once when it closes. +-- +-- Additive only: one nullable column, one new table, no rewrite. +ALTER TABLE "integration_statuses" ADD COLUMN IF NOT EXISTS "recent_calls" JSONB; + +CREATE TABLE IF NOT EXISTS "integration_watches" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "integration" TEXT NOT NULL, + "silent_since" TIMESTAMP(3), + "incident_reason" TEXT, + "incident_opened_at" TIMESTAMP(3), + "alerted_at" TIMESTAMP(3), + "checked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "integration_watches_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "integration_watches_user_id_integration_key" + ON "integration_watches"("user_id", "integration"); + +DO $$ BEGIN + ALTER TABLE "integration_watches" + ADD CONSTRAINT "integration_watches_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9834872e9..f00b0bfd2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -804,6 +804,7 @@ model User { idempotencyKeys IdempotencyKey[] devices Device[] integrationStatuses IntegrationStatus[] + integrationWatches IntegrationWatch[] coachConversations CoachConversation[] coachFacts CoachFact[] coachPlans CoachPlan[] @@ -5047,6 +5048,15 @@ model IntegrationStatus { // 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) + // The last ten sync attempts, newest first, as a JSON array of + // `{ endpoint, status, at, errorCode }`. `endpoint` is the sync leg name the + // code passes (never a URL), `status` is `ok` or the failure kind, and + // `errorCode` is a short closed-shape code ("401", "invalid_grant") or null. + // No message, no token, no value: this is what the redacted diagnostics on + // the connection health read publish, so it is shaped to be safe to copy + // into a bug report. Written by `appendRecentCall` in + // `src/lib/integrations/status.ts`, read by `resolveConnectionHealth`. + recentCalls Json? @map("recent_calls") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -5058,6 +5068,43 @@ model IntegrationStatus { @@map("integration_statuses") } +// What the integration watchdog (`src/lib/integrations/watchdog.ts`) knows +// about one connection: whether a source that normally delivers has gone +// silent, and whether an operator incident is open for it. One row per +// (user, connection) the watchdog has looked at. `integration` carries the +// connection key, which is the IntegrationStatus key set plus "apple-health", +// so it is a separate table rather than more columns on IntegrationStatus, +// whose readers assume every row is a polled provider. +// +// Operational, like IntegrationStatus: it records what this host observed, so +// it is excluded from backups and removed with the account's data. +model IntegrationWatch { + id String @id @default(cuid()) + userId String @map("user_id") + integration String + /// Set while the source is unexpectedly silent: the newest data instant at + /// the moment the watchdog flagged it. Null when the source is not flagged. + /// The connection health read surfaces it as `degraded` / + /// `unexpected_silence` until data arrives again. + silentSince DateTime? @map("silent_since") + /// Reason code of the operator incident currently open for this connection, + /// null when none is. One incident at a time per connection. + incidentReason String? @map("incident_reason") + incidentOpenedAt DateTime? @map("incident_opened_at") + /// When the operator was told about the open incident, or when the watchdog + /// adopted an alert the sync ledger had already sent for the same streak. + /// Null while an incident is open but not yet announced. + alertedAt DateTime? @map("alerted_at") + checkedAt DateTime @default(now()) @map("checked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, integration]) + @@map("integration_watches") +} + // ─── App Settings ───────────────────────────────────────── model AppSettings { diff --git a/src/app/api/fitbit/status/route.ts b/src/app/api/fitbit/status/route.ts index ac51d14f1..60a695256 100644 --- a/src/app/api/fitbit/status/route.ts +++ b/src/app/api/fitbit/status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; /** * Get Fitbit / Google Health connection status for the current user (v1.12.0). @@ -53,5 +54,9 @@ export const GET = apiHandler(async () => { tokenExpiresAt: connection.tokenExpiresAt, backfillCompleted: !!connection.backfillCompletedAt, scope: connection.scope, + // The one server-resolved state (ok / limited / degraded / + // action_required) every client renders. The fields above stay for + // clients that still read them; see `ConnectionHealth` in the API docs. + health: await loadOneConnectionHealth(user.id, "fitbit"), }); }); diff --git a/src/app/api/google-health/status/route.ts b/src/app/api/google-health/status/route.ts index 38b002f82..d63ea0c12 100644 --- a/src/app/api/google-health/status/route.ts +++ b/src/app/api/google-health/status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; /** * Get Google Health connection status for the current user (v1.26.0). @@ -58,5 +59,9 @@ export const GET = apiHandler(async () => { backfillCompleted: !!connection.backfillCompletedAt, needsReauth: connection.needsReauth, scope: connection.scope, + // The one server-resolved state (ok / limited / degraded / + // action_required) every client renders. The fields above stay for + // clients that still read them; see `ConnectionHealth` in the API docs. + health: await loadOneConnectionHealth(user.id, "google-health"), }); }); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 886e489ea..7a3307145 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -27,6 +27,7 @@ import { withHealthKitArrivals, } from "@/lib/integrations/healthkit-type-sync"; import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; +import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; import { classifyMetricFreshness, PUSH_CADENCE, @@ -173,10 +174,14 @@ export const GET = apiHandler(async () => { // progress; the phone's queue and throttle state stay on the phone and are // not guessed at. Fail-soft for the same reason the freshness read is — // a progress line is never worth 500-ing the config the iOS client needs. - const [samples, arrivals, syncProgress] = await Promise.all([ + // `health` is the unified connection state; fail-soft to null for the same + // reason as the reads beside it, so the config the phone needs never 500s + // over a diagnostic. + const [samples, arrivals, syncProgress, health] = await Promise.all([ getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), getHealthKitTypeArrivals(user.id).catch(() => null), getAppleHealthSyncProgress(user.id).catch(() => null), + loadOneConnectionHealth(user.id, "apple-health").catch(() => null), ]); // No `connected` hint: Apple Health has nothing to connect or disconnect, so // "never delivered" resolves to `pending_first_sync`, not `disconnected`. @@ -202,6 +207,7 @@ export const GET = apiHandler(async () => { arrivals, ), syncProgress, + health, }); }); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index fe97a54a2..f7a38cf98 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -51,6 +51,8 @@ import { import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { getStravaClientCredentials } from "@/lib/strava/credentials"; import { hasActivityScope } from "@/lib/withings/client"; +import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; export const dynamic = "force-dynamic"; @@ -159,6 +161,20 @@ export const GET = apiHandler(async () => { getStravaClientCredentials(user.id).then((c) => !!c), ]); + // The unified per-connection state. Resolved by the same loader the + // per-provider routes and the integration watchdog use, so the envelope and + // every other surface say the same thing about a connection. + const health = await loadConnectionHealth(user.id, [ + "withings", + "whoop", + "fitbit", + "google-health", + "polar", + "oura", + "strava", + "nightscout", + ]); + // F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a // single metric type has gone silent even while the integration reads green // ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth @@ -301,6 +317,7 @@ export const GET = apiHandler(async () => { parseWithingsApiPlanState(withingsConn.apiPlanState), ) : [], + health: health["withings"], ...resolve("withings", withingsStatus, { connected: !!withingsConn, configured: withingsConfigured, @@ -324,6 +341,7 @@ export const GET = apiHandler(async () => { backfillCompleted: whoopConnected ? !!whoopConn?.backfillCompletedAt : null, + health: health["whoop"], ...resolve("whoop", whoopStatus, { connected: whoopConnected, configured: whoopConfigured, @@ -341,6 +359,7 @@ export const GET = apiHandler(async () => { ? fitbitConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, + health: health["fitbit"], ...resolve("fitbit", fitbitStatus, { connected: fitbitConnected, configured: fitbitConfigured, @@ -364,6 +383,7 @@ export const GET = apiHandler(async () => { // user-revoked grant) surfaced from the connection row; the card reads it // to raise a distinct "Reconnect" CTA separate from parked/disconnected. needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false, + health: health["google-health"], ...resolve("google-health", googleHealthStatus, { connected: googleHealthConnected, configured: googleHealthConfigured, @@ -382,6 +402,7 @@ export const GET = apiHandler(async () => { hasOwnCredentials: !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted, + health: health["polar"], ...resolve("polar", polarStatus, { connected: polarConnected, configured: polarConnected, @@ -400,6 +421,7 @@ export const GET = apiHandler(async () => { scopeReconnect: ouraConnected ? ouraScopeReconnectList(readOuraScopeDenied(dbUser?.ouraScopeDenied)) : [], + health: health["oura"], ...resolve("oura", ouraStatus, { connected: ouraConnected, configured: ouraConnected, @@ -416,6 +438,7 @@ export const GET = apiHandler(async () => { !!dbUser?.stravaClientSecretEncrypted, // Strava writes Workout rows, not Measurements — its freshness list // carries the `WORKOUTS` pseudo-entry alone. + health: health["strava"], ...resolve("strava", stravaStatus, { connected: stravaConnected, configured: stravaConnected, @@ -431,6 +454,7 @@ export const GET = apiHandler(async () => { configured: nightscoutConfigured, hasToken: !!dbUser?.nightscoutTokenEncrypted, allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false, + health: health["nightscout"], ...resolve("nightscout", nightscoutStatus, { connected: nightscoutConfigured, configured: nightscoutConfigured, @@ -442,6 +466,16 @@ export const GET = apiHandler(async () => { interface IntegrationViewModel { integration: IntegrationKey; + /** + * The one server-resolved state per connection (ok / limited / degraded / + * action_required), null when the provider is not connected. Every other + * health-shaped field on this entry (`state`, `lastError`, + * `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, + * `hasActivityScope`, `planUnavailable`, `scopeReconnect`) is folded into + * it and goes once the settings cards render `health`; no other client + * reads this envelope. + */ + health: ConnectionHealth | null; state: string; lastSuccessAt: string | null; lastAttemptAt: string | null; diff --git a/src/app/api/nightscout/status/route.ts b/src/app/api/nightscout/status/route.ts index f2cda6aa0..dafd647ce 100644 --- a/src/app/api/nightscout/status/route.ts +++ b/src/app/api/nightscout/status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; import { getIntegrationStatus } from "@/lib/integrations/status"; import { INTEGRATION_CADENCE, @@ -62,5 +63,9 @@ export const GET = apiHandler(async () => { failingSinceAt: status.failingSince, cadence: INTEGRATION_CADENCE["nightscout"], }), + // The one server-resolved state (ok / limited / degraded / + // action_required) every client renders. The fields above stay for + // clients that still read them; see `ConnectionHealth` in the API docs. + health: await loadOneConnectionHealth(user.id, "nightscout"), }); }); diff --git a/src/app/api/oura/status/route.ts b/src/app/api/oura/status/route.ts index ba3abf574..20924f365 100644 --- a/src/app/api/oura/status/route.ts +++ b/src/app/api/oura/status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { getIntegrationStatus } from "@/lib/integrations/status"; import { @@ -70,5 +71,9 @@ export const GET = apiHandler(async () => { failingSinceAt: status.failingSince, cadence: INTEGRATION_CADENCE["oura"], }), + // The one server-resolved state (ok / limited / degraded / + // action_required) every client renders. The fields above stay for + // clients that still read them; see `ConnectionHealth` in the API docs. + health: await loadOneConnectionHealth(user.id, "oura"), }); }); diff --git a/src/app/api/polar/status/route.ts b/src/app/api/polar/status/route.ts index 92cd26d79..1f11d7f0e 100644 --- a/src/app/api/polar/status/route.ts +++ b/src/app/api/polar/status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { getIntegrationStatus } from "@/lib/integrations/status"; import { @@ -71,5 +72,9 @@ export const GET = apiHandler(async () => { failingSinceAt: status.failingSince, cadence: INTEGRATION_CADENCE["polar"], }), + // The one server-resolved state (ok / limited / degraded / + // action_required) every client renders. The fields above stay for + // clients that still read them; see `ConnectionHealth` in the API docs. + health: await loadOneConnectionHealth(user.id, "polar"), }); }); diff --git a/src/app/api/strava/status/route.ts b/src/app/api/strava/status/route.ts index c80c14a71..9906c250b 100644 --- a/src/app/api/strava/status/route.ts +++ b/src/app/api/strava/status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; import { getStravaClientCredentials } from "@/lib/strava/credentials"; import { getIntegrationStatus } from "@/lib/integrations/status"; import { @@ -70,5 +71,9 @@ export const GET = apiHandler(async () => { failingSinceAt: status.failingSince, cadence: INTEGRATION_CADENCE["strava"], }), + // The one server-resolved state (ok / limited / degraded / + // action_required) every client renders. The fields above stay for + // clients that still read them; see `ConnectionHealth` in the API docs. + health: await loadOneConnectionHealth(user.id, "strava"), }); }); diff --git a/src/app/api/whoop/status/route.ts b/src/app/api/whoop/status/route.ts index ae8ca1221..bfe462376 100644 --- a/src/app/api/whoop/status/route.ts +++ b/src/app/api/whoop/status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; /** * Get WHOOP connection status for the current user (v1.11.0). @@ -54,5 +55,9 @@ export const GET = apiHandler(async () => { tokenExpiresAt: connection.tokenExpiresAt, backfillCompleted: !!connection.backfillCompletedAt, scope: connection.scope, + // The one server-resolved state (ok / limited / degraded / + // action_required) every client renders. The fields above stay for + // clients that still read them; see `ConnectionHealth` in the API docs. + health: await loadOneConnectionHealth(user.id, "whoop"), }); }); diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 7e4311481..baeb2d754 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; +import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; import { decrypt, encrypt } from "@/lib/crypto"; import { getUserWithingsCredentials } from "@/lib/withings/credentials"; import { hasActivityScope, refreshAccessToken } from "@/lib/withings/client"; @@ -118,5 +119,9 @@ export const GET = apiHandler(async () => { // never re-authed since v1.4.25 — it counts as "missing activity". scope: connection.scope, hasActivityScope: hasActivityScope(connection.scope), + // The one server-resolved state (ok / limited / degraded / + // action_required) every client renders. The fields above stay for + // clients that still read them; see `ConnectionHealth` in the API docs. + health: await loadOneConnectionHealth(user.id, "withings"), }); }); diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index 88eb2b38d..a4d365a01 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -20,6 +20,7 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/db", () => ({ prisma: { + $executeRaw: vi.fn(), integrationStatus: { findUnique: vi.fn(), upsert: vi.fn(), @@ -363,8 +364,12 @@ describe("recordSyncFailure — alert window", () => { expect(dispatchNotification).not.toHaveBeenCalled(); }); - it("DOES dispatch a fresh alert after 24h have elapsed", async () => { - // Existing bucket at 5 → in-memory increment lands at 6. + it("does NOT re-page the same streak once 24h have elapsed", async () => { + // One page per streak. `alertedAt` is cleared by every path that ends a + // streak, so a stamped value means this streak was already announced, + // however long ago. The daily re-page this test used to pin turned a + // week-long outage into seven identical messages; the integration + // watchdog owns the rest of the incident, including its recovery note. vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({ consecutiveFailuresByKind: { transient: 5, @@ -377,15 +382,6 @@ describe("recordSyncFailure — alert window", () => { vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({ alertedAt: new Date(Date.now() - 25 * 60 * 60 * 1000), } as never); - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - email: "u@example.com", - } as never); - vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ - { id: "admin-1" }, - ] as never); - vi.mocked(prisma.integrationStatus.update).mockResolvedValueOnce( - {} as never, - ); await recordSyncFailure({ userId: "u1", @@ -394,7 +390,7 @@ describe("recordSyncFailure — alert window", () => { message: "Withings refresh error: 503 - upstream", }); - expect(dispatchNotification).toHaveBeenCalledOnce(); + expect(dispatchNotification).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/integrations/connection-health-load.ts b/src/lib/integrations/connection-health-load.ts new file mode 100644 index 000000000..a309f8253 --- /dev/null +++ b/src/lib/integrations/connection-health-load.ts @@ -0,0 +1,436 @@ +/** + * Gather what `resolveConnectionHealth` needs, for one account, in one place. + * + * Every surface that publishes a connection's health (the consolidated + * integrations envelope, the per-provider status routes, the Apple Health + * config read, the watchdog) calls this loader, so the four states cannot + * drift into one opinion per route. It reads: + * + * - the sync ledger rows (state, streak, failing legs, recent calls), + * - which connections exist and the provider-side extras that bear on + * health (Withings plan + activity scope, Oura scope gaps, Google Health + * `needsReauth`), + * - the watchdog's silence flags, + * - the newest data per connected source (one index probe each). + * + * Read-only. A connection that does not exist resolves to null. + */ +import { prisma } from "@/lib/db"; +import type { MeasurementSource } from "@/generated/prisma/client"; +import type { IntegrationKey } from "./status"; +import { + INTEGRATION_CADENCE, + PUSH_CADENCE, + resolveSyncVerdict, + type SyncHealth, +} from "./sync-verdict"; +import { + CONNECTION_KEYS, + RECENT_CALLS_LIMIT, + readRecentCalls, + resolveConnectionHealth, + type ConnectionHealth, + type ConnectionKey, + type RecentCall, +} from "./connection-health"; +import { SOURCE_PROFILES } from "./watchdog-classify"; +import { + INTEGRATION_MEASUREMENT_SOURCE, + INTEGRATION_WORKOUT_SOURCE, +} from "./metric-freshness"; +import { parseWithingsApiPlanState } from "@/lib/withings/api-plan"; +import { withingsPlanUnavailableFeatures } from "@/lib/withings/plan-features"; +import { hasActivityScope } from "@/lib/withings/client"; +import { readOuraScopeDenied } from "@/lib/oura/credentials"; +import { ouraScopeReconnectList } from "@/lib/oura/scope"; + +export type ConnectionHealthMap = Record< + ConnectionKey, + ConnectionHealth | null +>; + +/** Everything the loader read for one connection, beside the resolved health. */ +export interface ConnectionFacts { + key: ConnectionKey; + connected: boolean; + syncHealth: SyncHealth; + lastDataAt: Date | null; + /** The sync ledger holds an unresolved operator page for this streak. */ + ledgerAlerted: boolean; + /** Health resolved WITHOUT the watchdog's silence flag (the watchdog's own input). */ + healthWithoutSilence: ConnectionHealth | null; + /** Health as published (silence flag folded in). */ + health: ConnectionHealth | null; + silentSince: Date | null; +} + +interface LedgerRow { + integration: string; + state: string; + lastSuccessAt: Date | null; + lastAttemptAt: Date | null; + failingSinceAt: Date | null; + failingLegs: unknown; + recentCalls: unknown; + alertedAt: Date | null; +} + +/** Newest measurement of one source: one probe on `(userId, source, measuredAt DESC)`. */ +async function newestMeasurementAt( + userId: string, + source: MeasurementSource, +): Promise { + const row = await prisma.measurement.findFirst({ + where: { userId, source, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + select: { measuredAt: true }, + }); + return row?.measuredAt ?? null; +} + +async function newestWorkoutAt( + userId: string, + source: MeasurementSource, +): Promise { + const row = await prisma.workout.findFirst({ + where: { userId, source }, + orderBy: { startedAt: "desc" }, + select: { startedAt: true }, + }); + return row?.startedAt ?? null; +} + +function newestOf(...values: Array): Date | null { + let max: Date | null = null; + for (const v of values) { + if (v && (max === null || v.getTime() > max.getTime())) max = v; + } + return max; +} + +function iso(value: Date | null | undefined): string | null { + return value ? value.toISOString() : null; +} + +function readLegs(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter( + (v): v is string => + typeof v === "string" && /^[A-Za-z0-9_.:-]{1,48}$/.test(v), + ); +} + +/** + * Load the facts and resolved health for every connection key of one account. + * `keys` narrows the work for a single-provider route. + */ +export async function loadConnectionFacts( + userId: string, + keys: readonly ConnectionKey[] = CONNECTION_KEYS, + now: Date = new Date(), +): Promise> { + const wanted = new Set(keys); + const [ + ledgerRows, + watchRows, + dbUser, + withingsConn, + whoopConn, + fitbitConn, + googleHealthConn, + healthKitArrivals, + ] = await Promise.all([ + prisma.integrationStatus.findMany({ + where: { userId }, + select: { + integration: true, + state: true, + lastSuccessAt: true, + lastAttemptAt: true, + failingSinceAt: true, + failingLegs: true, + recentCalls: true, + alertedAt: true, + }, + }), + prisma.integrationWatch.findMany({ + where: { userId }, + select: { integration: true, silentSince: true }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { + polarAccessTokenEncrypted: true, + ouraAccessTokenEncrypted: true, + ouraScopeDenied: true, + stravaAccessTokenEncrypted: true, + nightscoutUrlEncrypted: true, + healthKitLastSyncedAt: true, + }, + }), + wanted.has("withings") + ? prisma.withingsConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true, scope: true, apiPlanState: true }, + }) + : null, + wanted.has("whoop") + ? prisma.whoopConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true, whoopUserId: true }, + }) + : null, + wanted.has("fitbit") + ? prisma.fitbitConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true }, + }) + : null, + wanted.has("google-health") + ? prisma.googleHealthConnection.findUnique({ + where: { userId }, + select: { lastSyncedAt: true, needsReauth: true }, + }) + : null, + wanted.has("apple-health") + ? prisma.healthKitTypeSync.findMany({ + where: { userId }, + select: { type: true, lastReceivedAt: true, lastNewSampleAt: true }, + orderBy: { lastReceivedAt: "desc" }, + }) + : [], + ]); + + const ledger = new Map( + ledgerRows.map((row) => [row.integration, row]), + ); + const silence = new Map( + watchRows.map((row) => [row.integration, row.silentSince]), + ); + + const connected: Record = { + withings: !!withingsConn, + whoop: !!whoopConn?.whoopUserId, + fitbit: !!fitbitConn, + "google-health": !!googleHealthConn, + polar: !!dbUser?.polarAccessTokenEncrypted, + oura: !!dbUser?.ouraAccessTokenEncrypted, + strava: !!dbUser?.stravaAccessTokenEncrypted, + nightscout: !!dbUser?.nightscoutUrlEncrypted, + // Apple Health has nothing to connect; it is "connected" once the phone + // has delivered anything (a live batch, or rows from the export import). + "apple-health": false, + }; + const legacyLastSyncedAt: Partial> = { + withings: withingsConn?.lastSyncedAt ?? null, + whoop: whoopConn?.whoopUserId ? (whoopConn.lastSyncedAt ?? null) : null, + fitbit: fitbitConn?.lastSyncedAt ?? null, + "google-health": googleHealthConn?.lastSyncedAt ?? null, + }; + + // Newest data per wanted, connected source. Apple Health is probed even + // before it counts as connected, because delivered data is what connects it. + const lastData = new Map(); + await Promise.all( + [...wanted].map(async (key) => { + if (key === "apple-health") { + const measured = await newestMeasurementAt(userId, "APPLE_HEALTH"); + lastData.set(key, measured); + return; + } + if (!connected[key]) return; + const mSource = INTEGRATION_MEASUREMENT_SOURCE[key as IntegrationKey]; + const wSource = INTEGRATION_WORKOUT_SOURCE[key as IntegrationKey]; + const [m, w] = await Promise.all([ + mSource ? newestMeasurementAt(userId, mSource) : null, + wSource ? newestWorkoutAt(userId, wSource) : null, + ]); + lastData.set(key, newestOf(m, w)); + }), + ); + + const out = new Map(); + const nowMs = now.getTime(); + + for (const key of keys) { + if (key === "apple-health") { + out.set( + key, + appleHealthFacts({ + lastSyncedAt: dbUser?.healthKitLastSyncedAt ?? null, + arrivals: healthKitArrivals, + newestMeasuredAt: lastData.get("apple-health") ?? null, + silentSince: silence.get(key) ?? null, + now, + }), + ); + continue; + } + + const row = ledger.get(key); + const isConnected = connected[key]; + const syncHealth = resolveSyncVerdict({ + connected: isConnected, + configured: isConnected, + state: row?.state ?? "unknown", + lastSuccessAt: row?.lastSuccessAt ?? null, + lastAttemptAt: row?.lastAttemptAt ?? null, + failingSinceAt: row?.failingSinceAt ?? null, + legacyLastSyncedAt: legacyLastSyncedAt[key] ?? null, + cadence: INTEGRATION_CADENCE[key], + now: nowMs, + }); + + let planUnavailable: string[] = []; + let permissionUnavailable: string[] = []; + if (key === "withings" && withingsConn) { + planUnavailable = withingsPlanUnavailableFeatures( + parseWithingsApiPlanState(withingsConn.apiPlanState), + ); + // A connection whose grant predates the activity scope cannot read + // activity or sleep; one reconnect asks for it. A null scope is the + // same legacy case `hasActivityScope` already treats as missing. + if (!hasActivityScope(withingsConn.scope ?? null)) { + permissionUnavailable = ["activity", "sleep"]; + } + } + if (key === "oura" && isConnected) { + permissionUnavailable = ouraScopeReconnectList( + readOuraScopeDenied(dbUser?.ouraScopeDenied), + ); + } + + const lastDataAt = lastData.get(key) ?? null; + const silentSince = silence.get(key) ?? null; + const common = { + syncHealth, + connected: isConnected, + ledgerState: row?.state ?? null, + lastSuccessAt: iso(row?.lastSuccessAt ?? legacyLastSyncedAt[key] ?? null), + lastAttemptAt: iso(row?.lastAttemptAt), + failingSince: iso(row?.failingSinceAt), + failingLegs: readLegs(row?.failingLegs), + recentCalls: readRecentCalls(row?.recentCalls), + lastDataAt: iso(lastDataAt), + needsReauth: key === "google-health" && !!googleHealthConn?.needsReauth, + planUnavailable, + permissionUnavailable, + silenceWindowMs: SOURCE_PROFILES[key].silenceWindowMs, + now: nowMs, + }; + out.set(key, { + key, + connected: isConnected, + syncHealth, + lastDataAt, + ledgerAlerted: row?.alertedAt != null, + healthWithoutSilence: resolveConnectionHealth({ + ...common, + silentSince: null, + }), + health: resolveConnectionHealth({ + ...common, + silentSince: iso(silentSince), + }), + silentSince, + }); + } + return out; +} + +function appleHealthFacts(input: { + lastSyncedAt: Date | null; + arrivals: Array<{ + type: string; + lastReceivedAt: Date; + lastNewSampleAt: Date | null; + }>; + newestMeasuredAt: Date | null; + silentSince: Date | null; + now: Date; +}): ConnectionFacts { + const newestReceived = newestOf( + ...input.arrivals.map((a) => a.lastReceivedAt), + ); + const newestNewSample = newestOf( + ...input.arrivals.map((a) => a.lastNewSampleAt), + ); + // "Last import with data": a sync that brought a new sample. Rows from the + // export import carry no arrival record, so their newest sample stands in. + const lastDataAt = newestNewSample ?? input.newestMeasuredAt; + const connected = + input.lastSyncedAt !== null || + newestReceived !== null || + input.newestMeasuredAt !== null; + // Same rule the config read uses (`newestAppleHealthDataAt`): anything that + // arrived counts as liveness for the push cadence. + const syncHealth = resolveSyncVerdict({ + configured: connected, + connected, + lastDataAt: newestOf( + input.lastSyncedAt, + newestReceived, + input.newestMeasuredAt, + ), + legacyLastSyncedAt: input.lastSyncedAt, + cadence: PUSH_CADENCE, + now: input.now, + }); + const recentCalls: RecentCall[] = input.arrivals + .slice(0, RECENT_CALLS_LIMIT) + .map((a) => ({ + endpoint: `batch:${a.type}`, + status: "ok", + at: a.lastReceivedAt.toISOString(), + errorCode: null, + })); + const common = { + syncHealth, + connected, + ledgerState: null, + lastSuccessAt: iso(input.lastSyncedAt ?? newestReceived), + lastAttemptAt: iso(newestReceived), + failingSince: null, + failingLegs: [], + recentCalls, + lastDataAt: iso(lastDataAt), + silenceWindowMs: SOURCE_PROFILES["apple-health"].silenceWindowMs, + now: input.now.getTime(), + }; + return { + key: "apple-health", + connected, + syncHealth, + lastDataAt, + ledgerAlerted: false, + healthWithoutSilence: resolveConnectionHealth({ + ...common, + silentSince: null, + }), + health: resolveConnectionHealth({ + ...common, + silentSince: iso(input.silentSince), + }), + silentSince: input.silentSince, + }; +} + +/** The published health per connection key, for routes. */ +export async function loadConnectionHealth( + userId: string, + keys: readonly ConnectionKey[] = CONNECTION_KEYS, +): Promise { + const facts = await loadConnectionFacts(userId, keys); + const out = {} as ConnectionHealthMap; + for (const key of CONNECTION_KEYS) out[key] = facts.get(key)?.health ?? null; + return out; +} + +/** One connection's published health, for a single-provider route. */ +export async function loadOneConnectionHealth( + userId: string, + key: ConnectionKey, +): Promise { + const facts = await loadConnectionFacts(userId, [key]); + return facts.get(key)?.health ?? null; +} diff --git a/src/lib/integrations/connection-health.ts b/src/lib/integrations/connection-health.ts new file mode 100644 index 000000000..fb5cd53e5 --- /dev/null +++ b/src/lib/integrations/connection-health.ts @@ -0,0 +1,390 @@ +/** + * One health state per connection, resolved on the server. + * + * Every integration used to express its condition in its own vocabulary: the + * ledger `state` (`error_transient`, `error_reauth`, `parked`), the liveness + * `syncHealth.verdict`, Withings' `planUnavailable` and `hasActivityScope`, + * Oura's `scopeReconnect`, Google Health's `needsReauth`, a token expiry, a + * backfill flag. Each card combined those itself, so the same fact could read + * as a red error on one card and as a footnote on the next. + * + * This module folds all of it into four states a person can act on: + * + * ok data is arriving (or the first sync is still due). + * limited it works, but some data types are unavailable because of + * the provider plan or a missing permission; `unavailable` + * names them. + * degraded temporary provider trouble or unexpected silence; the + * server keeps retrying. `since` dates it. + * action_required the person has to do something; `action` names the one + * thing. + * + * Plus a short reason code, the last successful sync, the newest data the + * source delivered, and a redacted diagnostics object that is safe to paste + * into a bug report: endpoint (leg) names, outcomes, instants and short error + * codes. Never a message, a token, a value or a user id. + * + * Pure: no Prisma, no I/O, type imports only, so the resolver is unit-testable + * and the types are importable from client code. The loader that gathers the + * inputs lives in `./connection-health-load`. + */ +import type { IntegrationKey } from "./status"; +import type { SyncHealth } from "./sync-verdict"; + +/** Every connection the health read covers: the polled providers + Apple Health. */ +export type ConnectionKey = IntegrationKey | "apple-health"; + +export const CONNECTION_KEYS: readonly ConnectionKey[] = [ + "withings", + "whoop", + "fitbit", + "google-health", + "polar", + "oura", + "strava", + "nightscout", + "apple-health", +]; + +export const CONNECTION_HEALTH_STATES = [ + "ok", + "limited", + "degraded", + "action_required", +] as const; +export type ConnectionHealthState = (typeof CONNECTION_HEALTH_STATES)[number]; + +/** + * Why the state is what it is. One code per state branch, so a client can pick + * its sentence from the code alone. + */ +export const CONNECTION_HEALTH_REASONS = [ + // ok + "data_arriving", + "first_sync_pending", + // limited + "plan_limited", + "permission_limited", + // degraded + "provider_errors", + "sync_stalled", + "no_new_data", + "unexpected_silence", + // action_required + "reauth_required", + "paused_after_errors", +] as const; +export type ConnectionHealthReason = (typeof CONNECTION_HEALTH_REASONS)[number]; + +/** The one thing a person can do about an `action_required` (or `limited`) connection. */ +export const CONNECTION_HEALTH_ACTIONS = ["reconnect", "resume"] as const; +export type ConnectionHealthAction = (typeof CONNECTION_HEALTH_ACTIONS)[number]; + +export interface UnavailableItem { + /** A provider data type key (`spo2`, `vo2Max`, `activity`, …). */ + item: string; + /** Withheld by the provider's plan, or not covered by the granted permission. */ + cause: "plan" | "permission"; +} + +/** Outcome of one recorded sync attempt. */ +export const RECENT_CALL_STATUSES = [ + "ok", + "transient", + "persistent", + "reauth_required", +] as const; +export type RecentCallStatus = (typeof RECENT_CALL_STATUSES)[number]; + +export interface RecentCall { + /** The sync leg name the code recorded (`sleep`, `measures`, `sync`), or for Apple Health `batch:`. */ + endpoint: string; + status: RecentCallStatus; + at: string; + /** A short error code (`401`, `invalid_grant`, `scope_missing`) or null. */ + errorCode: string | null; +} + +/** How many attempts the ring keeps and the diagnostics publish. */ +export const RECENT_CALLS_LIMIT = 10; + +export interface ConnectionDiagnostics { + /** The ledger state string as stored, for whoever reads a bug report. */ + ledgerState: string | null; + /** Sync legs currently recorded as failing. */ + failingLegs: string[]; + /** Start of the current failure streak, if any. */ + failingSince: string | null; + lastAttemptAt: string | null; + /** The liveness verdict the state was derived from. */ + verdict: SyncHealth["verdict"]; + /** Newest first, at most `RECENT_CALLS_LIMIT`. */ + recentCalls: RecentCall[]; +} + +export interface ConnectionHealth { + state: ConnectionHealthState; + reason: ConnectionHealthReason; + /** When the current non-ok condition started; null for `ok` and `limited`. */ + since: string | null; + /** The one action that resolves it; null when none is needed or possible. */ + action: ConnectionHealthAction | null; + /** Data types this connection cannot deliver; empty when nothing is withheld. */ + unavailable: UnavailableItem[]; + lastSuccessAt: string | null; + /** + * The newest data this source delivered. For polled providers, when the + * newest reading or workout was taken; for Apple Health, when a sync last + * brought a new sample. Null when nothing has arrived. + */ + lastDataAt: string | null; + diagnostics: ConnectionDiagnostics; +} + +export interface ConnectionHealthInput { + /** The liveness verdict from `resolveSyncVerdict`. */ + syncHealth: SyncHealth; + /** Not connected: there is no health to resolve. */ + connected: boolean; + ledgerState: string | null; + lastSuccessAt: string | null; + lastAttemptAt: string | null; + failingSince: string | null; + failingLegs: readonly string[]; + recentCalls: readonly RecentCall[]; + lastDataAt: string | null; + /** Provider-side reauth signal outside the ledger (Google Health `needsReauth`). */ + needsReauth?: boolean; + /** Types withheld by the provider plan (Withings). */ + planUnavailable?: readonly string[]; + /** Types the granted permission does not cover (Oura scopes, Withings activity scope). */ + permissionUnavailable?: readonly string[]; + /** + * The watchdog's unexpected-silence flag: the newest-data instant at the + * moment it flagged, or null when not flagged. + */ + silentSince?: string | null; + /** + * How long the source may go without data before the silence flag counts. + * A flag is ignored once `lastDataAt` is newer than this window, so a source + * that delivered after the last watchdog pass reads healthy at once. + */ + silenceWindowMs?: number; + now?: number; +} + +function toMs(value: string | null | undefined): number | null { + if (!value) return null; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +/** + * Fold the inputs into one state. First match wins, from the state that asks + * the most of the person down to the one that asks nothing: + * + * 1. action_required — the grant is gone, or the connection paused itself. + * 2. degraded — failing, stalled, stale, or unexpectedly silent. + * 3. limited — working, with named types withheld. + * 4. ok. + * + * Returns null for a connection that does not exist: there is nothing to say + * about its health, and a state would suggest otherwise. + */ +export function resolveConnectionHealth( + input: ConnectionHealthInput, +): ConnectionHealth | null { + if (!input.connected || input.syncHealth.verdict === "disconnected") { + return null; + } + const now = input.now ?? Date.now(); + const { verdict, since: verdictSince } = input.syncHealth; + + const unavailable: UnavailableItem[] = [ + ...dedupe(input.planUnavailable).map((item): UnavailableItem => ({ + item, + cause: "plan", + })), + ...dedupe(input.permissionUnavailable).map((item): UnavailableItem => ({ + item, + cause: "permission", + })), + ]; + + const diagnostics: ConnectionDiagnostics = { + ledgerState: input.ledgerState, + failingLegs: [...input.failingLegs], + failingSince: input.failingSince, + lastAttemptAt: input.lastAttemptAt, + verdict, + recentCalls: input.recentCalls.slice(0, RECENT_CALLS_LIMIT), + }; + + const base = { + unavailable, + lastSuccessAt: input.lastSuccessAt, + lastDataAt: input.lastDataAt, + diagnostics, + }; + + // 1. The person has to act. + if (verdict === "reauth_required" || input.needsReauth) { + return { + ...base, + state: "action_required", + reason: "reauth_required", + since: verdictSince ?? input.failingSince, + action: "reconnect", + }; + } + if (verdict === "parked") { + return { + ...base, + state: "action_required", + reason: "paused_after_errors", + since: verdictSince ?? input.failingSince, + action: "resume", + }; + } + + // 2. Temporary trouble; the server keeps trying. + if (verdict === "failing") { + return { + ...base, + state: "degraded", + reason: "provider_errors", + since: verdictSince, + action: null, + }; + } + if (verdict === "stalled") { + return { + ...base, + state: "degraded", + reason: "sync_stalled", + since: verdictSince, + action: null, + }; + } + if (verdict === "stale") { + return { + ...base, + state: "degraded", + reason: "no_new_data", + since: verdictSince, + action: null, + }; + } + if (isSilenceStillTrue(input, now)) { + return { + ...base, + state: "degraded", + reason: "unexpected_silence", + since: input.silentSince ?? null, + action: null, + }; + } + + // 3. Working, with something withheld. A permission gap is fixable by one + // reconnect; a plan gap is not fixable here at all. + const permissionGap = unavailable.some((u) => u.cause === "permission"); + if (unavailable.length > 0) { + return { + ...base, + state: "limited", + reason: permissionGap ? "permission_limited" : "plan_limited", + since: null, + action: permissionGap ? "reconnect" : null, + }; + } + + // 4. Fine. + return { + ...base, + state: "ok", + reason: + verdict === "pending_first_sync" ? "first_sync_pending" : "data_arriving", + since: null, + action: null, + }; +} + +/** + * The watchdog flag stands only while no data newer than the silence window + * has arrived. The watchdog clears it on its next pass; this keeps the read + * honest in the hour between. + */ +function isSilenceStillTrue( + input: ConnectionHealthInput, + now: number, +): boolean { + if (!input.silentSince) return false; + const window = input.silenceWindowMs; + const lastData = toMs(input.lastDataAt); + if (window != null && lastData != null && now - lastData <= window) { + return false; + } + return true; +} + +function dedupe(values: readonly string[] | undefined): string[] { + if (!values) return []; + return [...new Set(values)]; +} + +// ── Recent-call ring ───────────────────────────────────────────────── + +/** Leg names and error codes are identifiers; anything else is dropped. */ +const SAFE_TOKEN = /^[A-Za-z0-9_.:-]{1,48}$/; + +/** A leg name safe to publish, or `sync` when the caller passed none or something unexpected. */ +export function safeEndpointName(value: string | null | undefined): string { + return value && SAFE_TOKEN.test(value) ? value : "sync"; +} + +/** + * An error code safe to publish, or null. Codes are short identifiers the sync + * code passes (`401`, `invalid_grant`, `scope_missing`); a value that does not + * look like one (a sentence, a URL, something carrying a token) is dropped + * rather than trimmed, because a trimmed secret is still a secret. + */ +export function safeErrorCode(value: string | null | undefined): string | null { + return value && SAFE_TOKEN.test(value) ? value : null; +} + +/** + * Read the stored JSON ring into typed entries, newest first. Anything that is + * not a well-formed entry is skipped; every string is re-checked against the + * same closed shapes the writer applies, so a hand-edited row cannot publish + * free text through the diagnostics. + */ +export function readRecentCalls(value: unknown): RecentCall[] { + if (!Array.isArray(value)) return []; + const out: RecentCall[] = []; + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const entry = raw as Record; + const status = entry.status; + const at = entry.at; + if ( + typeof status !== "string" || + !(RECENT_CALL_STATUSES as readonly string[]).includes(status) || + typeof at !== "string" || + !Number.isFinite(Date.parse(at)) + ) { + continue; + } + out.push({ + endpoint: safeEndpointName( + typeof entry.endpoint === "string" ? entry.endpoint : null, + ), + status: status as RecentCallStatus, + at: new Date(at).toISOString(), + errorCode: safeErrorCode( + typeof entry.errorCode === "string" ? entry.errorCode : null, + ), + }); + if (out.length >= RECENT_CALLS_LIMIT) break; + } + return out; +} diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index d00fbdad5..57bac23f8 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -40,6 +40,12 @@ import { type DispatchOutcome, } from "@/lib/notifications/dispatcher"; import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier"; +import { + RECENT_CALLS_LIMIT, + safeEndpointName, + safeErrorCode, + type RecentCallStatus, +} from "@/lib/integrations/connection-health"; export type IntegrationKey = | "withings" @@ -194,13 +200,14 @@ export function getPersistentFailureThreshold(): number { } /** - * Re-alert window: once we've paged on a streak we hold the alert for - * 24h before paging again on the same streak (idempotency). The streak - * is implicitly "reset" by a single success, which clears every - * per-kind bucket and `alertedAt` — so a flapping integration that - * succeeds once an hour will not page repeatedly. + * One page per failure streak. `alertedAt` is cleared by every path that ends + * a streak (a success entitled to clear the row, a reconnect, a resume, a + * disconnect), so a stamped `alertedAt` means "this streak was already + * announced". It used to re-page every 24 h for as long as the streak ran, + * which turned a week-long outage into seven identical messages; the + * integration watchdog (`./watchdog`) now owns the rest of the incident, + * including the one recovery note when it ends. */ -const ALERT_REPEAT_WINDOW_MS = 24 * 60 * 60 * 1000; /** * v1.4.43 W14 — once a `persistent` failure streak has been running @@ -474,6 +481,12 @@ export async function recordSyncSuccess( ...(ownedLastError ? { lastError: null } : {}), }, }); + await appendRecentCall(userId, integration, { + endpoint: leg, + status: "ok", + at: now, + errorCode: null, + }); // Meta only — the enclosing wide event owns its own action name (the // cron task), and overwriting it here would rename the job. annotate({ @@ -516,6 +529,12 @@ export async function recordSyncSuccess( alertedAt: null, }, }); + await appendRecentCall(userId, integration, { + endpoint: leg, + status: "ok", + at: now, + errorCode: null, + }); } export interface RecordSyncFailureInput { @@ -718,6 +737,13 @@ export async function recordSyncFailure( }, }); + await appendRecentCall(userId, integration, { + endpoint: leg, + status: effectiveKind, + at: now, + errorCode: errorCode ?? null, + }); + // Audit log entry — fire-and-await so an integration test can assert // it. The auth/audit helper is its own DB write so it's safe to call // serially without bloating latency in the success path (which never @@ -771,9 +797,7 @@ export async function recordSyncFailure( const threshold = getPersistentFailureThreshold(); const alertSignal = Math.max(...Object.values(buckets)); if (alertSignal >= threshold) { - const previouslyAlerted = - row.alertedAt && - now.getTime() - row.alertedAt.getTime() < ALERT_REPEAT_WINDOW_MS; + const previouslyAlerted = row.alertedAt != null; if (!previouslyAlerted) { const outcome = await maybeAlertAdmins({ userId, @@ -970,6 +994,13 @@ export async function parkIntegrationAtReauth(opts: { }, }); + await appendRecentCall(userId, integration, { + endpoint: leg, + status: "reauth_required", + at: now, + errorCode, + }); + if (isFreshPark) { await auditLog("integrations.reauth_required", { userId, @@ -1024,6 +1055,13 @@ export async function markReauthRequired( }, }); + await appendRecentCall(userId, integration, { + endpoint: "token_refresh", + status: "reauth_required", + at: now, + errorCode: null, + }); + await auditLog("integrations.reauth_required", { userId, details: { integration, message }, @@ -1090,6 +1128,64 @@ export async function markReconnected( // ── helpers ──────────────────────────────────────────────────────── +interface RecentCallWrite { + endpoint: string | null; + status: RecentCallStatus; + at: Date; + errorCode: string | null; +} + +/** + * Prepend one attempt to the row's `recentCalls` ring and keep the newest + * `RECENT_CALLS_LIMIT`. One statement, no read: the ring is rebuilt inside + * Postgres, so a hot sync path pays one UPDATE rather than a read-modify-write. + * + * The entry is built from closed shapes only. The leg name and error code go + * through the same filters the reader applies (`safeEndpointName`, + * `safeErrorCode`), and the failure message is never part of it, so nothing a + * provider sent can reach the diagnostics a user copies into a bug report. + * + * Fail-soft: the ring is diagnostics. A failed append must never fail or undo + * the ledger write it describes, so the error becomes a wide-event warning. + */ +async function appendRecentCall( + userId: string, + integration: IntegrationKey, + call: RecentCallWrite, +): Promise { + const entry = JSON.stringify({ + endpoint: safeEndpointName(call.endpoint), + status: call.status, + at: call.at.toISOString(), + errorCode: safeErrorCode(call.errorCode), + }); + try { + // Parameter-bound throughout; the ring is rebuilt newest-first and cut at + // the limit. A row whose column is NULL or not an array starts empty. + await prisma.$executeRaw` + UPDATE "integration_statuses" + SET "recent_calls" = ( + SELECT COALESCE(jsonb_agg(e ORDER BY ord), '[]'::jsonb) + FROM ( + SELECT e, ord + FROM jsonb_array_elements( + jsonb_build_array(${entry}::jsonb) || + CASE WHEN jsonb_typeof("recent_calls") = 'array' + THEN "recent_calls" ELSE '[]'::jsonb END + ) WITH ORDINALITY AS t(e, ord) + ORDER BY ord + LIMIT ${RECENT_CALLS_LIMIT} + ) newest + ) + WHERE "user_id" = ${userId} AND "integration" = ${integration} + `; + } catch (err) { + getEvent()?.addWarning( + `IntegrationStatus recent-call append failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + /** * Encrypt with a fallback that swallows crypto-config errors so a * misconfigured ENCRYPTION_KEY can never break the audit/error path. @@ -1247,7 +1343,36 @@ async function maybeAlertAdmins(input: AlertInput): Promise { where: { id: input.userId }, select: { email: true }, }); + const payload = formatAdminAlertPayload({ + ...input, + subjectLabel: subject?.email ?? input.userId, + }); + return dispatchAdminSystemAlert({ + subjectUserId: input.userId, + title: payload.title, + message: payload.message, + metadata: payload.metadata, + }); +} +/** + * Send one `SYSTEM_ALERT` to every admin through the existing dispatcher, the + * operator path the sync ledger and the integration watchdog share. Each admin + * receives it on whatever channels they enabled (Telegram, ntfy, Web Push, + * APNs); nothing here adds a channel. + * + * The returned outcome aggregates the dispatcher's real per-admin outcomes, so + * one attempted delivery cannot be hidden by another admin having no enabled + * channel. A caller stamps its own "already announced" marker only when + * `channelsAttempted > 0`. + */ +export async function dispatchAdminSystemAlert(input: { + /** The account the alert is about; picks the tap target per admin. */ + subjectUserId: string; + title: string; + message: string; + metadata: Record; +}): Promise { const admins = await prisma.user.findMany({ where: { role: "ADMIN" }, select: { id: true }, @@ -1261,10 +1386,7 @@ async function maybeAlertAdmins(input: AlertInput): Promise { }; } - const payload = formatAdminAlertPayload({ - ...input, - subjectLabel: subject?.email ?? input.userId, - }); + const payload = input; const outcome: DispatchOutcome = { dispatched: false, channelsAttempted: 0, @@ -1282,7 +1404,7 @@ async function maybeAlertAdmins(input: AlertInput): Promise { // Tap target: an admin whose own sync fails fixes it in their own // integration settings; anyone else's shows on the admin page. url: - admin.id === input.userId + admin.id === input.subjectUserId ? "/settings/integrations" : "/admin/integrations", }, diff --git a/src/lib/integrations/watchdog-classify.ts b/src/lib/integrations/watchdog-classify.ts new file mode 100644 index 000000000..cc1951e36 --- /dev/null +++ b/src/lib/integrations/watchdog-classify.ts @@ -0,0 +1,213 @@ +/** + * The integration watchdog's judgement, kept pure. + * + * Two questions per connection, answered from data the runner gathers: + * + * 1. Is a source that normally delivers unexpectedly silent? + * Only a CONTINUOUS source can be: a wearable, a CGM, a phone that + * records steps all day. Its own history decides "normally": it must have + * delivered on at least `NORMAL_DELIVERY_MIN_DAYS` of the previous + * `HISTORY_DAYS` days. An ON-DEMAND source (a scale, a blood-pressure + * cuff, a workout log) is silent whenever its owner does not measure, + * which says nothing about the pipe, so silence never counts for it; it + * is watched for errors only. + * + * 2. Is there an incident the operator should hear about? + * Unexpected silence; or a health state of `action_required` / + * `degraded` that has lasted longer than `INCIDENT_AFTER_MS`; or a + * failure streak the sync ledger already paged about (adopted, so the + * operator still gets the one recovery note when it ends). + * + * One incident per connection at a time. It is announced once and closed once; + * a change of reason while it is open (silence turning into a reauth) is the + * same incident and sends nothing. + */ +import type { + ConnectionHealth, + ConnectionHealthReason, + ConnectionKey, +} from "./connection-health"; + +export type SourceCadenceKind = "continuous" | "on_demand"; + +export interface SourceProfile { + kind: SourceCadenceKind; + /** How long a continuous source may go without data before silence counts. */ + silenceWindowMs: number; +} + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +/** How many days of a connection's own history decide "normally delivers". */ +export const HISTORY_DAYS = 14; + +/** Days with data, out of `HISTORY_DAYS`, that make a source "normally delivering". */ +export const NORMAL_DELIVERY_MIN_DAYS = 10; + +/** How long a non-ok health state must last before it is an operator incident. */ +export const INCIDENT_AFTER_MS = DAY_MS; + +/** + * Per connection. Withings is mostly scales and cuffs, Strava is workouts: + * both are on-demand. Everything worn or attached around the clock is + * continuous. + * + * Apple Health gets 48 h instead of 24 h. iOS defers background delivery when + * the phone is low on storage, in low-power mode or simply not unlocked, and a + * day of that is ordinary; the history gate already limits the check to + * phones that deliver almost daily, and the longer window keeps an ordinary + * quiet day from paging anyone. + */ +export const SOURCE_PROFILES: Record = { + withings: { kind: "on_demand", silenceWindowMs: DAY_MS }, + strava: { kind: "on_demand", silenceWindowMs: DAY_MS }, + whoop: { kind: "continuous", silenceWindowMs: DAY_MS }, + oura: { kind: "continuous", silenceWindowMs: DAY_MS }, + fitbit: { kind: "continuous", silenceWindowMs: DAY_MS }, + "google-health": { kind: "continuous", silenceWindowMs: DAY_MS }, + polar: { kind: "continuous", silenceWindowMs: DAY_MS }, + nightscout: { kind: "continuous", silenceWindowMs: DAY_MS }, + "apple-health": { kind: "continuous", silenceWindowMs: 2 * DAY_MS }, +}; + +/** Incident reason codes. The silence one is the watchdog's own. */ +export type IncidentReason = ConnectionHealthReason; + +export interface WatchRow { + silentSince: Date | null; + incidentReason: string | null; + incidentOpenedAt: Date | null; + alertedAt: Date | null; +} + +export interface SilenceInput { + profile: SourceProfile; + /** Newest data instant, or null when the source never delivered. */ + lastDataAt: Date | null; + /** + * Distinct days with data in the `HISTORY_DAYS` before the silence window, + * or null when the runner has not computed it (it only does when needed). + */ + deliveredDays: number | null; + /** The current flag, so an already-flagged source is not re-judged hourly. */ + currentSilentSince: Date | null; + now: Date; +} + +export type SilenceVerdict = + /** Data is inside the window (or the source is on-demand): no flag. */ + | { kind: "clear" } + /** Silent, and the history has to be read before deciding. */ + | { kind: "needs_history" } + /** Silent, and the history says it should not be. */ + | { kind: "flag"; silentSince: Date } + /** Silent, but it does not normally deliver: the owner is not using it. */ + | { kind: "not_expected" }; + +/** + * Decide the silence flag. `deliveredDays` is only needed for a silent, + * not-yet-flagged continuous source (the hourly pass reads it lazily) and for a + * re-check of a flag (the daily pass passes it so a source its owner has + * stopped using drops out once its history no longer says it delivers). + */ +export function classifySilence(input: SilenceInput): SilenceVerdict { + const { profile, lastDataAt, now } = input; + if (profile.kind === "on_demand") return { kind: "clear" }; + // A source that never delivered has no history to break with. + if (lastDataAt === null) return { kind: "clear" }; + if (now.getTime() - lastDataAt.getTime() <= profile.silenceWindowMs) { + return { kind: "clear" }; + } + if (input.deliveredDays === null) { + // Already flagged and nothing asked for a re-check: keep the flag as is. + if (input.currentSilentSince) { + return { kind: "flag", silentSince: input.currentSilentSince }; + } + return { kind: "needs_history" }; + } + if (input.deliveredDays >= NORMAL_DELIVERY_MIN_DAYS) { + return { + kind: "flag", + silentSince: input.currentSilentSince ?? lastDataAt, + }; + } + return { kind: "not_expected" }; +} + +/** + * The incident condition right now, or null when there is none. + * + * `health` is the resolved connection health WITHOUT the watchdog's own + * silence flag folded in, so the silence condition and the state condition + * stay separate. `ledgerAlerted` is true while the sync ledger holds an + * unresolved page for the current streak. + */ +export function currentIncident(input: { + health: ConnectionHealth | null; + silentSince: Date | null; + ledgerAlerted: boolean; + now: Date; +}): { reason: IncidentReason; since: Date } | null { + const { health, silentSince, now } = input; + if (health === null) return null; + const troubled = + health.state === "action_required" || health.state === "degraded"; + if (troubled) { + // A provider-side reauth flag (Google Health) can stand without a ledger + // streak to date it; the last success is then when it last worked. + const sinceIso = health.since ?? health.lastSuccessAt; + const since = sinceIso ? new Date(sinceIso) : null; + const lasted = + since !== null && now.getTime() - since.getTime() > INCIDENT_AFTER_MS; + if (lasted || input.ledgerAlerted) { + return { reason: health.reason, since: since ?? now }; + } + } + if (silentSince) { + return { reason: "unexpected_silence", since: silentSince }; + } + return null; +} + +export type WatchTransition = + /** No incident before, none now. */ + | { kind: "none" } + /** A new incident: announce it. */ + | { kind: "open"; reason: IncidentReason; since: Date } + /** A new incident the ledger already announced: record it, send nothing. */ + | { kind: "adopt"; reason: IncidentReason; since: Date } + /** Still open: send nothing. `announce` when the open incident was never announced (no channel last time). */ + | { kind: "keep"; reason: IncidentReason; announce: boolean } + /** Over. `notify` when the operator had been told it started. */ + | { kind: "close"; notify: boolean; previousReason: string }; + +/** + * Compare the open incident (if any) with the current condition. Pure; the + * runner performs the side effects. + */ +export function decideWatchTransition(input: { + row: WatchRow | null; + incident: { reason: IncidentReason; since: Date } | null; + ledgerAlerted: boolean; +}): WatchTransition { + const open = input.row?.incidentReason ?? null; + if (open === null) { + if (input.incident === null) return { kind: "none" }; + return input.ledgerAlerted + ? { kind: "adopt", ...input.incident } + : { kind: "open", ...input.incident }; + } + if (input.incident === null) { + return { + kind: "close", + notify: input.row?.alertedAt != null, + previousReason: open, + }; + } + return { + kind: "keep", + reason: input.incident.reason, + announce: input.row?.alertedAt == null && !input.ledgerAlerted, + }; +} From dad9a7778e84cbd7c45ab8b5d0c851fd6d643a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:02:38 +0200 Subject: [PATCH 02/40] fix(medications): keep an extra Apple Health dose off a slot that already holds one A HealthKit dose event binds to a slot by window band, and a weekly schedule's band reaches a day either side of its slot. An extra dose logged the day after a scheduled one landed on that slot and overwrote it last-write-wins: the slot's time changed and the extra dose was stored nowhere. An external dose record whose band slot already carries a different take is now recorded as its own unscheduled dose. The dose history now lets the take stored on a slot claim it before an extra dose stored on its own instant, instead of following the order the rows were read in, so the extra dose reads as off-schedule. --- .../intake/bulk/__tests__/route.test.ts | 6 +- src/app/api/medications/intake/bulk/route.ts | 29 ++ .../scheduling/__tests__/extra-dose.test.ts | 114 ++++++ .../medications/scheduling/dose-history.ts | 27 +- src/lib/medications/scheduling/slot-upsert.ts | 73 ++++ ...medication-extra-dose-apple-health.test.ts | 356 ++++++++++++++++++ 6 files changed, 602 insertions(+), 3 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/extra-dose.test.ts create mode 100644 tests/integration/medication-extra-dose-apple-health.test.ts diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 464d57691..c3ac9d2a3 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -1380,9 +1380,11 @@ describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events", }); it("stamps the externalId onto the pending slot row when the Apple dose converges by band", async () => { - // Dedup pre-check first (empty), then the canonical-slot find returns - // the worker-minted pending REMINDER row for the 19:00 slot. + // Dedup pre-check first (empty), then the held-dose probe (no take on + // the slot yet), then the canonical-slot find returns the worker-minted + // pending REMINDER row for the 19:00 slot. vi.mocked(prisma.medicationIntakeEvent.findMany) + .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([ { diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 69fd01a1f..37202fac4 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -65,6 +65,7 @@ import { resolveForcedSlotForWrite, resolveSlotForWriteByBand, resolveSlotInstantForWrite, + slotHeldByAnotherDose, } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import { @@ -338,6 +339,9 @@ async function postBulk(request: NextRequest): Promise { let inserted = 0; let updated = 0; let duplicates = 0; + // External dose records kept as their own unscheduled dose because their + // band slot already carried a different one. + let extraDosesUnscheduled = 0; const skipped: Array<{ index: number; reason: string }> = []; const unstableShapes: UnstableExternalIdShape[] = []; // v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs @@ -443,6 +447,9 @@ async function postBulk(request: NextRequest): Promise { // a band decision. Pending echoes / skips carry no decision // (undefined → the upsert leaves an existing USER_PIN untouched). let attributionSource: "AUTO" | "USER_PIN" | undefined; + // Set when the band slot is held by a different dose and this take is + // recorded unscheduled; it must not converge onto any slot row below. + let heldByAnotherDose = false; if (isExplicitTaken && entry.forceSlotInstant !== undefined) { canonicalSlot = await resolveForcedSlotForWrite({ userId: user.id, @@ -486,6 +493,26 @@ async function postBulk(request: NextRequest): Promise { }); canonicalSlot = attribution.slotInstant; attributionSource = "AUTO"; + // An external dose record (a HealthKit dose event) whose band slot + // already carries a different recorded dose is an extra dose, not a + // correction of that one. Converging would overwrite the slot's + // take last-write-wins and the extra dose would exist nowhere; it is + // recorded as its own unscheduled dose instead. + if ( + canonicalSlot !== null && + entry.externalId !== undefined && + (await slotHeldByAnotherDose({ + userId: user.id, + medicationId: entry.medicationId, + canonicalSlot, + takenAt: entry.takenAt as Date, + externalId: entry.externalId, + })) + ) { + canonicalSlot = null; + heldByAnotherDose = true; + extraDosesUnscheduled += 1; + } } else if (isExplicitSkip && entry.scheduledFor === undefined) { // v1.16.9 — a slot-less deliberate skip resolves by band // membership on the skip moment, exactly like a take does. The @@ -666,6 +693,7 @@ async function postBulk(request: NextRequest): Promise { // late-morning dose consuming the evening slot). It records // standalone (ad-hoc) instead. const existingSlotRow = + !heldByAnotherDose && entry.scheduledFor !== undefined && mayConvergeOntoSuppliedSlot({ skipped: entry.skipped, @@ -878,6 +906,7 @@ async function postBulk(request: NextRequest): Promise { updated, duplicates, skipped: skipped.length, + extra_doses_unscheduled: extraDosesUnscheduled, ...(unstableShapes.length > 0 ? unstableExternalIdMeta("medication.intake.bulk", unstableShapes) : {}), diff --git a/src/lib/medications/scheduling/__tests__/extra-dose.test.ts b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts new file mode 100644 index 000000000..67457ce71 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts @@ -0,0 +1,114 @@ +/** + * An extra dose next to a slot that already carries one. + * + * Two ends: the write path keeps a different external dose record off a slot + * that is already taken (`isAnotherDose`), and the read ledger lets the dose + * stored on the slot claim it before an extra one stored on its own instant, + * whatever order the rows were read in. + */ +import { describe, expect, it } from "vitest"; + +import { buildSlotBands, type SlotWindowInput } from "../attribution"; +import { reconstructDoseHistory, type HistoryIntake } from "../dose-history"; +import { SAME_DOSE_TOLERANCE_MS, isAnotherDose } from "../slot-upsert"; + +const MIN = 60_000; +const t0 = new Date("2026-06-09T20:00:00Z"); +const plus = (minutes: number) => new Date(t0.getTime() + minutes * MIN); + +describe("isAnotherDose", () => { + it("a slot with nothing taken is never held by another dose", () => { + expect( + isAnotherDose( + { takenAt: null, externalId: null }, + { takenAt: plus(600), externalId: "hk-2" }, + ), + ).toBe(false); + }); + + it("the same external record is the same dose", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + { takenAt: plus(600), externalId: "hk-1" }, + ), + ).toBe(false); + }); + + it("two external records a day apart are two doses", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + { takenAt: plus(24 * 60 + 22), externalId: "hk-2" }, + ), + ).toBe(true); + }); + + it("two external records within a minute are one dose entered twice", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + { takenAt: new Date(t0.getTime() + 30_000), externalId: "hk-2" }, + ), + ).toBe(false); + }); + + it("a take with no external record merges within the tolerance only", () => { + const held = { takenAt: t0, externalId: null }; + expect( + isAnotherDose(held, { + takenAt: new Date(t0.getTime() + SAME_DOSE_TOLERANCE_MS), + externalId: "hk-2", + }), + ).toBe(false); + expect(isAnotherDose(held, { takenAt: plus(30), externalId: "hk-2" })).toBe( + true, + ); + }); +}); + +describe("reconstructDoseHistory: the slot's own dose claims the slot", () => { + const slot: SlotWindowInput = { + at: t0, + timeOfDay: "22:00", + onTimeStart: plus(-60), + onTimeEnd: plus(60), + lateGraceMs: 180 * MIN, + }; + const bands = buildSlotBands([slot]); + const onSlot: HistoryIntake = { + id: "on-slot", + scheduledFor: t0, + takenAt: plus(5), + skipped: false, + }; + const extra: HistoryIntake = { + id: "extra", + scheduledFor: plus(30), + takenAt: plus(30), + skipped: false, + }; + + it.each([ + ["slot row first", [onSlot, extra]], + ["extra row first (newest scheduledFor first)", [extra, onSlot]], + ])("%s", (_label, intakes) => { + const rows = reconstructDoseHistory(bands, intakes, plus(600), null); + const slotRow = rows.find((row) => row.kind === "slot"); + const adHoc = rows.filter((row) => row.kind === "ad_hoc"); + expect(slotRow?.intake?.id).toBe("on-slot"); + expect(adHoc.map((row) => row.intake?.id)).toEqual(["extra"]); + }); + + it("among takes not stored on the slot, the nearer one claims it", () => { + const far: HistoryIntake = { ...extra, id: "far" }; + const near: HistoryIntake = { + id: "near", + scheduledFor: plus(10), + takenAt: plus(10), + skipped: false, + }; + const rows = reconstructDoseHistory(bands, [far, near], plus(600), null); + expect(rows.find((row) => row.kind === "slot")?.intake?.id).toBe("near"); + }); +}); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index b4d31aef3..a126eff2c 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -273,8 +273,33 @@ export function reconstructDoseHistory( } } - for (const i of taken) { + // The best fit claims a slot first, whatever order the caller read the rows + // in. A take the write path stored ON the slot it attributes to goes before + // one stored on its own instant (an extra dose recorded unscheduled), then + // on-time before late, then the nearer take. Processing in the caller's + // order (newest `scheduledFor` first on the history route) let an extra dose + // logged half an hour after the slot claim it and pushed the slot's own + // dose out as the off-schedule one. + const ranked = taken.map((i) => { const matched = attributeIntakeToSlot(i.takenAt as Date, bands); + if (!matched) return { i, matched, key: [2, 0, 0] as const }; + const storedOnSlot = + Math.abs(i.scheduledFor.getTime() - matched.band.at.getTime()) <= + ANCHOR_EPSILON_MS; + return { + i, + matched, + key: [ + storedOnSlot ? 0 : 1, + matched.status === "on_time" ? 0 : 1, + Math.abs((i.takenAt as Date).getTime() - matched.band.at.getTime()), + ] as const, + }; + }); + ranked.sort( + (a, b) => a.key[0] - b.key[0] || a.key[1] - b.key[1] || a.key[2] - b.key[2], + ); + for (const { i, matched } of ranked) { if (matched && !claim.has(matched.band)) { claim.set(matched.band, { intake: i, diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 55eef2a2b..fa1d63343 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -746,6 +746,79 @@ export async function findPinConflict(input: { }); } +/** + * How far apart two recorded takes of one slot may sit and still be the same + * dose logged twice (a tap in HealthLog and an entry in another app a few + * minutes later). Further apart, they are two doses. + */ +export const SAME_DOSE_TOLERANCE_MS = 15 * 60_000; + +/** + * Is `held` (the take a slot already carries) a different dose from the + * incoming external dose record? + * + * Band attribution maps a take to the slot whose band contains it, and a + * weekly schedule's band reaches a day either side of the slot. Without this + * check an extra dose logged the day after a Tuesday dose landed on the + * Tuesday slot and overwrote it last-write-wins: the Tuesday time was + * replaced and the extra dose existed nowhere. + * + * - Nothing taken on the slot yet (pending, skipped): not another dose, the + * incoming take is the slot's dose. + * - The same external record: the same dose. + * - Two different external records: different doses, unless they sit within + * a minute of each other, which is a double entry rather than two doses. + * - Otherwise (the held take has no external record): the same dose within + * {@link SAME_DOSE_TOLERANCE_MS}, a different one beyond it. + */ +export function isAnotherDose( + held: { takenAt: Date | null; externalId: string | null }, + incoming: { takenAt: Date; externalId: string | null }, +): boolean { + if (held.takenAt === null) return false; + if (held.externalId !== null && held.externalId === incoming.externalId) { + return false; + } + const gap = Math.abs(held.takenAt.getTime() - incoming.takenAt.getTime()); + if (held.externalId !== null && incoming.externalId !== null) { + return gap > 60_000; + } + return gap > SAME_DOSE_TOLERANCE_MS; +} + +/** + * Does the slot at `canonicalSlot` already carry a recorded take that is a + * different dose from the incoming one (see {@link isAnotherDose})? The + * caller records the incoming take as its own unscheduled dose instead of + * converging onto the slot. + */ +export async function slotHeldByAnotherDose(input: { + userId: string; + medicationId: string; + canonicalSlot: Date; + takenAt: Date; + externalId: string | null; + client?: Pick; +}): Promise { + const client = input.client ?? defaultPrisma; + const rows = await client.medicationIntakeEvent.findMany({ + where: { + userId: input.userId, + medicationId: input.medicationId, + scheduledFor: input.canonicalSlot, + deletedAt: null, + takenAt: { not: null }, + }, + select: { takenAt: true, externalId: true }, + }); + return rows.some((row) => + isAnotherDose(row, { + takenAt: input.takenAt, + externalId: input.externalId, + }), + ); +} + export interface ResolveForcedSlotInput { userId: string; medicationId: string; diff --git a/tests/integration/medication-extra-dose-apple-health.test.ts b/tests/integration/medication-extra-dose-apple-health.test.ts new file mode 100644 index 000000000..df8465703 --- /dev/null +++ b/tests/integration/medication-extra-dose-apple-health.test.ts @@ -0,0 +1,356 @@ +/** + * An extra (off-schedule) dose recorded in Apple Health must arrive as its own + * unscheduled dose. + * + * The phone posts every HealthKit dose event to the bulk route with the + * event's UUID as `externalId`, `scheduledFor` set to the event's scheduled + * date or, for an extra dose that has none, its own time. A taken write binds + * to a slot by window band, and a weekly schedule's band reaches a day either + * side of its slot. An extra dose on the Wednesday after a Tuesday dose + * therefore landed on the Tuesday slot and overwrote it: the Tuesday time was + * replaced by the Wednesday one and the extra dose existed nowhere. + * + * Three shapes, each through the shipped `POST` and `GET` exports: an extra + * dose far from any slot, one half an hour after a slot already taken, and one + * on a day the schedule has no slot. Each asserts the stored rows AND the dose + * history the app reads back, where the extra dose must show as off-schedule + * and the slot keep its own dose. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { localHmAsUtc } from "@/lib/tz/local-day"; + +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), +})); + +const TZ = "Europe/Berlin"; +const DAY_MS = 24 * 60 * 60 * 1000; +const MIN = 60_000; + +let counter = 0; + +async function signedInUser(): Promise { + const prisma = getPrismaClient(); + const suffix = `extra-dose-${counter++}`; + const user = await prisma.user.create({ + data: { + username: suffix, + email: `${suffix}@example.test`, + role: "USER", + timezone: TZ, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user.id; +} + +async function seedMedication( + userId: string, + schedule: { timesOfDay: string[]; daysOfWeek: string | null }, +): Promise { + const med = await getPrismaClient().medication.create({ + data: { + userId, + name: "Magnesium", + dose: "400mg", + active: true, + // Dose events from Apple Health only target a mirrored medication. + externalSource: "APPLE_HEALTH", + externalId: `hk-medication-${userId}`, + startsOn: new Date(Date.now() - 60 * DAY_MS), + // The ledger expects slots only from the medication's creation on. + createdAt: new Date(Date.now() - 60 * DAY_MS), + schedules: { + create: { + windowStart: schedule.timesOfDay[0], + windowEnd: schedule.timesOfDay[0], + timesOfDay: schedule.timesOfDay, + daysOfWeek: schedule.daysOfWeek, + scheduleType: "SCHEDULED", + }, + }, + }, + }); + return med.id; +} + +/** A local day at least two weeks back with the given weekday (0 = Sunday). */ +function pastLocalDay(weekday: number): Date { + const day = new Date(Date.now() - 21 * DAY_MS); + day.setUTCHours(12, 0, 0, 0); + while (day.getUTCDay() !== weekday) { + day.setTime(day.getTime() - DAY_MS); + } + return day; +} + +interface BulkEntry { + medicationId: string; + scheduledFor: string; + takenAt: string; + externalId: string; +} + +/** The phone's shape: one HealthKit dose event per entry. */ +function appleDose( + medicationId: string, + takenAt: Date, + externalId: string, + scheduledFor: Date = takenAt, +): BulkEntry { + return { + medicationId, + scheduledFor: scheduledFor.toISOString(), + takenAt: takenAt.toISOString(), + externalId, + }; +} + +async function postBulk(entries: BulkEntry[]) { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const response = await POST( + new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + entries: entries.map((entry) => ({ + ...entry, + source: "APPLE_HEALTH", + })), + }), + }), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { entries: Array<{ status: string; id?: string }> }; + }; + return body.data.entries; +} + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +interface LedgerRow { + kind: "slot" | "ad_hoc"; + at: string; + status: string; + intake: { id: string | null; takenAt: string | null } | null; +} + +async function readLedger( + medicationId: string, + from: Date, + to: Date, +): Promise { + const { GET } = await import("@/app/api/medications/[id]/dose-history/route"); + const search = new URLSearchParams({ + from: from.toISOString(), + to: to.toISOString(), + }); + const response = await (GET as Handler)( + new NextRequest( + `http://localhost/api/medications/${medicationId}/dose-history?${search}`, + { method: "GET" }, + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: { rows: LedgerRow[] } }; + return body.data.rows; +} + +async function rowsOf(medicationId: string) { + return getPrismaClient().medicationIntakeEvent.findMany({ + where: { medicationId, deletedAt: null }, + orderBy: { takenAt: "asc" }, + }); +} + +function takenAtOf(row: LedgerRow | undefined): number | null { + const taken = row?.intake?.takenAt; + return taken ? new Date(taken).getTime() : null; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("an extra dose from Apple Health is stored as an unscheduled dose", () => { + it("(a) far from any slot", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["08:00"], + daysOfWeek: null, + }); + const day = pastLocalDay(3); + const slot = localHmAsUtc(day, TZ, 8, 0); + const extraAt = localHmAsUtc(day, TZ, 15, 0); + + const results = await postBulk([ + appleDose(medicationId, new Date(slot.getTime() + 5 * MIN), "hk-a-1"), + appleDose(medicationId, extraAt, "hk-a-2"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted", "inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const extra = rows.find((row) => row.externalId === "hk-a-2"); + expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime()); + expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 18 * 3_600_000), + ); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc).toHaveLength(1); + expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime()); + const slotRow = ledger.find((row) => row.kind === "slot"); + expect(slotRow?.status).toBe("taken_on_time"); + }); + + it("(b) 30 minutes after a slot already taken", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + const day = pastLocalDay(2); + const slot = localHmAsUtc(day, TZ, 22, 0); + const ownAt = new Date(slot.getTime() + 2 * MIN); + const extraAt = new Date(slot.getTime() + 30 * MIN); + + // The slot's own dose first, then the extra one in a later sync. + expect( + (await postBulk([appleDose(medicationId, ownAt, "hk-b-1", slot)])).map( + (r) => r.status, + ), + ).toEqual(["inserted"]); + expect( + (await postBulk([appleDose(medicationId, extraAt, "hk-b-2")])).map( + (r) => r.status, + ), + ).toEqual(["inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const own = rows.find((row) => row.externalId === "hk-b-1"); + const extra = rows.find((row) => row.externalId === "hk-b-2"); + // The slot keeps its own dose, time unchanged. + expect(own?.scheduledFor.getTime()).toBe(slot.getTime()); + expect(own?.takenAt?.getTime()).toBe(ownAt.getTime()); + // The extra dose sits on its own instant. + expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime()); + expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 6 * 3_600_000), + ); + const slotRow = ledger.find((row) => row.kind === "slot"); + expect(slotRow?.status).toBe("taken_on_time"); + expect(takenAtOf(slotRow)).toBe(ownAt.getTime()); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc).toHaveLength(1); + expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime()); + }); + + it("(b2) a re-sync of the same dose event stays one dose", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + const slot = localHmAsUtc(pastLocalDay(2), TZ, 22, 0); + const ownAt = new Date(slot.getTime() + 2 * MIN); + + await postBulk([appleDose(medicationId, ownAt, "hk-b2-1", slot)]); + const again = await postBulk([ + appleDose(medicationId, ownAt, "hk-b2-1", slot), + ]); + expect(again.map((r) => r.status)).toEqual(["duplicate"]); + expect(await rowsOf(medicationId)).toHaveLength(1); + }); + + it("(c) on a day without a slot, next to the weekly slot the day before", async () => { + const userId = await signedInUser(); + // Tuesdays and Fridays at 22:00. + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: "2,5", + }); + const tuesday = pastLocalDay(2); + const wednesday = new Date(tuesday.getTime() + DAY_MS); + const tuesdaySlot = localHmAsUtc(tuesday, TZ, 22, 0); + const tuesdayDose = localHmAsUtc(tuesday, TZ, 20, 23); + const wednesdayDose = localHmAsUtc(wednesday, TZ, 20, 45); + + await postBulk([appleDose(medicationId, tuesdayDose, "hk-c-tue")]); + const results = await postBulk([ + appleDose(medicationId, wednesdayDose, "hk-c-wed"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const tue = rows.find((row) => row.externalId === "hk-c-tue"); + const wed = rows.find((row) => row.externalId === "hk-c-wed"); + // Tuesday keeps its own time. + expect(tue?.scheduledFor.getTime()).toBe(tuesdaySlot.getTime()); + expect(tue?.takenAt?.getTime()).toBe(tuesdayDose.getTime()); + // Wednesday is its own unscheduled dose. + expect(wed?.scheduledFor.getTime()).toBe(wednesdayDose.getTime()); + expect(wed?.takenAt?.getTime()).toBe(wednesdayDose.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(tuesday.getTime() - 2 * DAY_MS), + new Date(tuesday.getTime() + 2 * DAY_MS), + ); + const tuesdayRow = ledger.find( + (row) => + row.kind === "slot" && + new Date(row.at).getTime() === tuesdaySlot.getTime(), + ); + expect(takenAtOf(tuesdayRow)).toBe(tuesdayDose.getTime()); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc.map(takenAtOf)).toEqual([wednesdayDose.getTime()]); + }); +}); From 82d89600445472ea892e90eb3e84d45d7ee47526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:07:28 +0200 Subject: [PATCH 03/40] fix(jobs): run every admitted import inside its own background event The admission drain ran each full-history import with no wide event in scope, so every external call, warning and annotation the provider walk recorded was dropped and a failing backfill left only its error message. Each admitted run now opens a job.integration_backfill event with its kind, identity, job id and slice, emitted when the run settles. The Health Connect import worker gets the same treatment. The external call list on one event is bounded so a long walk cannot hold thousands of entries: failed and annotated calls keep the larger share, the rest is counted in external_calls_omitted. --- .../integration-backfill-drain-event.test.ts | 117 ++++++++++++++++++ src/lib/jobs/health-connect-import-worker.ts | 14 ++- src/lib/jobs/integration-backfill-drain.ts | 45 ++++++- .../logging/__tests__/event-builder.test.ts | 49 +++++++- src/lib/logging/event-builder.ts | 24 ++++ src/lib/logging/types.ts | 5 + 6 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 src/lib/jobs/__tests__/integration-backfill-drain-event.test.ts diff --git a/src/lib/jobs/__tests__/integration-backfill-drain-event.test.ts b/src/lib/jobs/__tests__/integration-backfill-drain-event.test.ts new file mode 100644 index 000000000..e6b8421af --- /dev/null +++ b/src/lib/jobs/__tests__/integration-backfill-drain-event.test.ts @@ -0,0 +1,117 @@ +/** + * #1220 — every admitted full-history import runs inside its own background + * event, so what the provider walk records on the ambient event (each + * request with its status, every warning, every annotation) reaches the log. + * Before, the drain ran the import with no event in scope, every one of those + * calls was a no-op, and a failing backfill left nothing but its error + * message behind. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Job } from "pg-boss"; + +import type { WideEvent } from "@/lib/logging/types"; + +const { emitted, runGoogleHealthBackfillMock } = vi.hoisted(() => ({ + emitted: [] as WideEvent[], + runGoogleHealthBackfillMock: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: (event: WideEvent) => emitted.push(event), +})); +vi.mock("@/lib/jobs/integration-backfill-lock", () => ({ + withIntegrationBackfillLock: async (_id: string, run: () => unknown) => ({ + ran: true, + result: await run(), + }), +})); +vi.mock("@/lib/jobs/google-health-backfill", () => ({ + runGoogleHealthBackfillForUser: runGoogleHealthBackfillMock, +})); +vi.mock("@/lib/jobs/reminder/shared", () => ({ workerLog: () => {} })); + +import { drainIntegrationBackfillAdmission } from "../integration-backfill-drain"; +import type { IntegrationBackfillAdmissionPayload } from "../integration-backfill-admission"; +import { annotate, getEvent } from "@/lib/logging/context"; + +function job( + data: IntegrationBackfillAdmissionPayload, +): Job { + return { + id: "job-1", + data, + expireInSeconds: 3600, + } as unknown as Job; +} + +const payload: IntegrationBackfillAdmissionPayload = { + kind: "google-health-backfill", + data: { userId: "u1", enqueuedAt: "2026-10-14T11:00:00Z", slice: 2 }, +}; + +beforeEach(() => { + emitted.length = 0; + runGoogleHealthBackfillMock.mockReset(); +}); + +describe("drainIntegrationBackfillAdmission — one event per admitted run", () => { + it("emits what the import recorded, with the job it belongs to", async () => { + runGoogleHealthBackfillMock.mockImplementation(async () => { + getEvent()?.addExternalCall({ + service: "google-health", + method: "fetchHeartRate(page=0)", + duration_ms: 120, + status: 200, + }); + annotate({ meta: { "googleHealth.backfill.slice": { pages: 1 } } }); + return { imported: 1000, continued: true }; + }); + + await drainIntegrationBackfillAdmission([job(payload)]); + + expect(emitted).toHaveLength(1); + const [event] = emitted; + expect(event!.background).toMatchObject({ + task_name: "job.integration_backfill", + result: { kind: "google-health-backfill", imported: 1000, continued: 1 }, + }); + expect(event!.meta).toMatchObject({ + kind: "google-health-backfill", + identity: "google-health-backfill|u1", + job_id: "job-1", + slice: 2, + "googleHealth.backfill.slice": { pages: 1 }, + }); + expect(event!.external_calls).toEqual([ + expect.objectContaining({ + method: "fetchHeartRate(page=0)", + status: 200, + }), + ]); + }); + + it("emits the calls of a run that throws, with its error", async () => { + runGoogleHealthBackfillMock.mockImplementation(async () => { + getEvent()?.addExternalCall({ + service: "google-health", + method: "fetchHeartRate(page=812)", + duration_ms: 15000, + status: 503, + error: "transient HTTP 503", + }); + throw new Error("google-health backfill incomplete for user u1"); + }); + + await expect( + drainIntegrationBackfillAdmission([job(payload)]), + ).rejects.toThrow(/incomplete/); + + expect(emitted).toHaveLength(1); + expect(emitted[0]!.level).toBe("error"); + expect(emitted[0]!.error?.message).toMatch(/incomplete/); + expect(emitted[0]!.external_calls?.[0]).toMatchObject({ + method: "fetchHeartRate(page=812)", + status: 503, + }); + }); +}); diff --git a/src/lib/jobs/health-connect-import-worker.ts b/src/lib/jobs/health-connect-import-worker.ts index 074caf22e..58ba0c6db 100644 --- a/src/lib/jobs/health-connect-import-worker.ts +++ b/src/lib/jobs/health-connect-import-worker.ts @@ -32,6 +32,7 @@ import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalida import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { annotate } from "@/lib/logging/context"; +import { withBackgroundEvent } from "@/lib/logging/background"; import { recomputeUserRollups, ROLLUP_FOLD_WINDOW_MS, @@ -102,7 +103,18 @@ export async function handleHealthConnectImport( ): Promise { let failure: JobOutcome | null = null; for (const job of jobs) { - const outcome = await runHealthConnectImport(job.data); + // Its own event per import, so the `annotate` calls below and anything + // the import records on the ambient event reach the log instead of + // vanishing for want of one. + const outcome = await withBackgroundEvent( + "job.health_connect_import", + async (event) => { + event.addMeta("import_job_id", job.data.importJobId); + const result = await runHealthConnectImport(job.data); + if (!result.ok) event.setError(result.cause ?? result.reason); + return result; + }, + ); if (!outcome.ok && failure === null) failure = outcome; } return failure ?? jobDone({ jobs: jobs.length }); diff --git a/src/lib/jobs/integration-backfill-drain.ts b/src/lib/jobs/integration-backfill-drain.ts index 68d7f7174..df6ad556e 100644 --- a/src/lib/jobs/integration-backfill-drain.ts +++ b/src/lib/jobs/integration-backfill-drain.ts @@ -143,6 +143,49 @@ async function runAdmitted( } } +/** The task name of the event each admitted run writes. */ +export const INTEGRATION_BACKFILL_RUN_TASK = "job.integration_backfill"; + +/** + * Run one admitted job inside its own background event. + * + * The runners write what they do through the ambient event: every request a + * provider walk makes (`addExternalCall`, with its status, duration and the + * page it asked for), every warning, every `annotate`. Without an event in + * scope each of those is a no-op, so a full-history import that failed left + * nothing behind but the job's own error message: not the collection it was + * on, not the status the provider answered, not how far it got (#1220). The + * hourly syncs always ran inside one; the imports did not. Every kind gets + * one here, emitted when the run settles, thrown error included. + */ +async function runObserved( + job: Job, + identity: string, + shouldStop: () => boolean, + totals: RunCounts, +): Promise { + await withBackgroundEvent(INTEGRATION_BACKFILL_RUN_TASK, async (event) => { + event.addMeta("kind", job.data.kind); + event.addMeta("identity", identity); + event.addMeta("job_id", job.id); + event.addMeta("slice", job.data.data.slice ?? 0); + const before = { ...totals }; + await runAdmitted(job.data, shouldStop, totals); + event.setBackground({ + task_name: INTEGRATION_BACKFILL_RUN_TASK, + result: { + kind: job.data.kind, + imported: totals.imported - before.imported, + continued: totals.continued - before.continued, + removed: totals.removed - before.removed, + deleted: totals.deleted - before.deleted, + markers: totals.markers - before.markers, + linked: totals.linked - before.linked, + }, + }); + }); +} + /** * Drain a batch of admission jobs. There is no per-job error isolation: a * runner that throws fails the job and the admission retry policy applies. So @@ -166,7 +209,7 @@ export async function drainIntegrationBackfillAdmission( for (const job of jobs) { const identity = integrationBackfillIdentity(job.data); const run = await withIntegrationBackfillLock(identity, () => - runAdmitted(job.data, shouldStop, totals), + runObserved(job, identity, shouldStop, totals), ); if (!run.ran) { alreadyRunning += 1; diff --git a/src/lib/logging/__tests__/event-builder.test.ts b/src/lib/logging/__tests__/event-builder.test.ts index 2ff65ce65..c8900f8f9 100644 --- a/src/lib/logging/__tests__/event-builder.test.ts +++ b/src/lib/logging/__tests__/event-builder.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { WideEventBuilder } from "../event-builder"; +import { + EXTERNAL_CALLS_MAX, + EXTERNAL_CALLS_PLAIN_MAX, + WideEventBuilder, +} from "../event-builder"; describe("WideEventBuilder.setHttp", () => { it("stores plain http metadata as-is", () => { @@ -140,3 +144,46 @@ describe("WideEventBuilder.capStatusLevel", () => { expect(evt.toJSON().level).toBe("warn"); }); }); + +describe("WideEventBuilder — bounded external calls", () => { + it("lists failures beyond the share of plain successes, and counts the rest", () => { + const evt = new WideEventBuilder("background"); + for (let i = 0; i < 500; i++) { + evt.addExternalCall({ + service: "google-health", + method: `page=${i}`, + duration_ms: 1, + status: 200, + }); + } + evt.addExternalCall({ + service: "google-health", + method: "page=500", + duration_ms: 1, + status: 503, + error: "transient HTTP 503", + }); + const event = evt.toJSON(); + expect(event.external_calls).toHaveLength(EXTERNAL_CALLS_PLAIN_MAX + 1); + expect(event.external_calls!.at(-1)).toMatchObject({ status: 503 }); + expect(event.external_calls_omitted).toBe(500 - EXTERNAL_CALLS_PLAIN_MAX); + + for (let i = 0; i < 1000; i++) { + evt.addExternalCall({ + service: "google-health", + method: `retry=${i}`, + duration_ms: 1, + status: 429, + note: "rate_limited_retrying_in_ms=2000", + }); + } + const after = evt.toJSON(); + expect(after.external_calls).toHaveLength(EXTERNAL_CALLS_MAX); + expect(after.external_calls_omitted).toBe( + 500 - + EXTERNAL_CALLS_PLAIN_MAX + + 1000 - + (EXTERNAL_CALLS_MAX - EXTERNAL_CALLS_PLAIN_MAX - 1), + ); + }); +}); diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 09afd9819..2429e9d59 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -35,6 +35,12 @@ function redactDeep(value: T, depth = 0): T { return value; } +/** The most external calls one event lists. */ +export const EXTERNAL_CALLS_MAX = 200; + +/** The most calls without an error or a note one event lists. */ +export const EXTERNAL_CALLS_PLAIN_MAX = 60; + /** * Baut ein Wide Event Schritt fuer Schritt auf. * Wird am Anfang eines Requests/Tasks erstellt und am Ende emittiert. @@ -44,6 +50,7 @@ export class WideEventBuilder { private startTime: number; private highestLevel: LogLevel = "info"; private statusLevelCeiling: LogLevel = "error"; + private plainExternalCalls = 0; constructor(kind: EventKind = "http") { this.startTime = performance.now(); @@ -208,6 +215,23 @@ export class WideEventBuilder { addExternalCall(call: NonNullable[0]): this { if (!this.event.external_calls) this.event.external_calls = []; + // Bounded. A long background run (a full-history backfill walks hundreds + // of pages in one event) would otherwise hold every call in memory and + // write them all as one log line. Calls that failed or carry a note are + // what a reader looks for, so they keep the larger share; plain successes + // stop being listed sooner. Whatever is left out is counted, never lost + // silently. + const calls = this.event.external_calls; + const notable = call.error !== undefined || call.note !== undefined; + if ( + calls.length >= EXTERNAL_CALLS_MAX || + (!notable && this.plainExternalCalls >= EXTERNAL_CALLS_PLAIN_MAX) + ) { + this.event.external_calls_omitted = + (this.event.external_calls_omitted ?? 0) + 1; + return this; + } + if (!notable) this.plainExternalCalls += 1; // An outbound failure carries whatever the remote said, and for several // integrations that is the request URL — which for Telegram embeds the bot // token. `setError` and `setHttp` scrub on the way in; this entry point did diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts index 086357230..a126d5681 100644 --- a/src/lib/logging/types.ts +++ b/src/lib/logging/types.ts @@ -123,6 +123,11 @@ export interface WideEvent { */ note?: string; }>; + /** + * How many external calls the event did not list because it had already + * listed its share (`EXTERNAL_CALLS_MAX`, `EXTERNAL_CALLS_PLAIN_MAX`). + */ + external_calls_omitted?: number; // Hintergrundaufgabe (nur bei kind === "background") background?: { From c094f217fc4542d56ae83397bf9af08460de1231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:07:57 +0200 Subject: [PATCH 04/40] feat(integrations): watch connections for silence and lasting trouble An hourly and a daily pass flag a continuous source that delivered on most of the previous 14 days and has gone quiet, and open one operator incident per connection when a connection stays degraded or needs action for more than a day. The incident is announced once through the existing admin alert path, adopted rather than repeated when the sync ledger already paged, and closed with one recovery note. Scales, cuffs and workout logs are watched for errors only, never for silence. --- src/lib/data-wipe/wipe-plan.ts | 1 + src/lib/export/backup-plan.ts | 2 + .../__tests__/connection-health.test.ts | 209 +++++++++ .../__tests__/watchdog-classify.test.ts | 320 +++++++++++++ src/lib/integrations/watchdog.ts | 430 ++++++++++++++++++ src/lib/jobs/integration-watchdog.ts | 71 +++ src/lib/jobs/job-outcome.ts | 5 + src/lib/jobs/queue-runtime.ts | 6 + .../reminder/register-integration-sync.ts | 40 ++ src/lib/jobs/subsystem-surface.ts | 2 + .../integration/integration-watchdog.test.ts | 280 ++++++++++++ 11 files changed, 1366 insertions(+) create mode 100644 src/lib/integrations/__tests__/connection-health.test.ts create mode 100644 src/lib/integrations/__tests__/watchdog-classify.test.ts create mode 100644 src/lib/integrations/watchdog.ts create mode 100644 src/lib/jobs/integration-watchdog.ts create mode 100644 tests/integration/integration-watchdog.test.ts diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index da0dbb4af..a247fd823 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -231,6 +231,7 @@ export const WIPE_MODELS = [ "GoogleHealthOAuthState", "McpOAuthConnection", "IntegrationStatus", + "IntegrationWatch", // ── Notification transports ───────────────────────────────────────────── "NotificationChannel", diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 340e4b68e..94d737678 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -670,6 +670,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "A person's API token for their Paperless-ngx or Papra instance. A backup file is not a credential store, same reasoning as ApiToken; and restored on another host it would point at an address that host's operator never allowed in DOCUMENT_SOURCE_ORIGINS. The documents imported through it are carried with the vault; reconnecting takes one form.", IntegrationStatus: "A ledger of what this deployment's syncs did. Restoring it onto another host would assert a sync history that host never had.", + IntegrationWatch: + "What this host's integration watchdog observed and which operator incidents it announced. Restored elsewhere it would assert silences and alerts that host never saw.", // Credentials and device identity. ApiToken: diff --git a/src/lib/integrations/__tests__/connection-health.test.ts b/src/lib/integrations/__tests__/connection-health.test.ts new file mode 100644 index 000000000..5c0db5353 --- /dev/null +++ b/src/lib/integrations/__tests__/connection-health.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import { + readRecentCalls, + resolveConnectionHealth, + safeErrorCode, + type ConnectionHealthInput, +} from "../connection-health"; +import type { SyncHealth } from "../sync-verdict"; + +const NOW = Date.parse("2026-10-15T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; + +function input( + verdict: SyncHealth["verdict"], + extra: Partial = {}, +): ConnectionHealthInput { + return { + syncHealth: { + verdict, + since: verdict === "fresh" ? null : "2026-10-14T08:00:00.000Z", + }, + connected: true, + ledgerState: "connected", + lastSuccessAt: "2026-10-15T11:00:00.000Z", + lastAttemptAt: "2026-10-15T11:00:00.000Z", + failingSince: null, + failingLegs: [], + recentCalls: [], + lastDataAt: "2026-10-15T10:00:00.000Z", + silenceWindowMs: 24 * HOUR, + now: NOW, + ...extra, + }; +} + +describe("resolveConnectionHealth", () => { + it("returns null for a connection that does not exist", () => { + expect( + resolveConnectionHealth(input("fresh", { connected: false })), + ).toBeNull(); + expect(resolveConnectionHealth(input("disconnected"))).toBeNull(); + }); + + it("maps a fresh verdict to ok / data_arriving", () => { + const h = resolveConnectionHealth(input("fresh"))!; + expect(h.state).toBe("ok"); + expect(h.reason).toBe("data_arriving"); + expect(h.action).toBeNull(); + expect(h.since).toBeNull(); + }); + + it("maps a pending first sync to ok / first_sync_pending, not to a problem", () => { + const h = resolveConnectionHealth(input("pending_first_sync"))!; + expect(h.state).toBe("ok"); + expect(h.reason).toBe("first_sync_pending"); + }); + + it.each([ + ["reauth_required", "reauth_required", "reconnect"], + ["parked", "paused_after_errors", "resume"], + ] as const)( + "maps %s to action_required with exactly one action", + (verdict, reason, action) => { + const h = resolveConnectionHealth(input(verdict))!; + expect(h.state).toBe("action_required"); + expect(h.reason).toBe(reason); + expect(h.action).toBe(action); + expect(h.since).toBe("2026-10-14T08:00:00.000Z"); + }, + ); + + it("treats a provider-side reauth flag as action_required even on a fresh ledger", () => { + const h = resolveConnectionHealth(input("fresh", { needsReauth: true }))!; + expect(h.state).toBe("action_required"); + expect(h.action).toBe("reconnect"); + }); + + it.each([ + ["failing", "provider_errors"], + ["stalled", "sync_stalled"], + ["stale", "no_new_data"], + ] as const)("maps %s to degraded / %s, no action", (verdict, reason) => { + const h = resolveConnectionHealth(input(verdict))!; + expect(h.state).toBe("degraded"); + expect(h.reason).toBe(reason); + expect(h.action).toBeNull(); + expect(h.since).not.toBeNull(); + }); + + it("lists plan-withheld types as limited without an action", () => { + const h = resolveConnectionHealth( + input("fresh", { planUnavailable: ["spo2", "ecg", "spo2"] }), + )!; + expect(h.state).toBe("limited"); + expect(h.reason).toBe("plan_limited"); + expect(h.action).toBeNull(); + expect(h.unavailable).toEqual([ + { item: "spo2", cause: "plan" }, + { item: "ecg", cause: "plan" }, + ]); + }); + + it("offers one reconnect when a permission gap is part of the limit", () => { + const h = resolveConnectionHealth( + input("fresh", { + planUnavailable: ["ecg"], + permissionUnavailable: ["spo2"], + }), + )!; + expect(h.state).toBe("limited"); + expect(h.reason).toBe("permission_limited"); + expect(h.action).toBe("reconnect"); + }); + + it("lets a worse state win over limited, keeping the unavailable list", () => { + const h = resolveConnectionHealth( + input("failing", { planUnavailable: ["ecg"] }), + )!; + expect(h.state).toBe("degraded"); + expect(h.unavailable).toEqual([{ item: "ecg", cause: "plan" }]); + }); + + it("surfaces the watchdog silence flag as degraded / unexpected_silence", () => { + const h = resolveConnectionHealth( + input("fresh", { + lastDataAt: "2026-10-13T12:00:00.000Z", + silentSince: "2026-10-13T12:00:00.000Z", + }), + )!; + expect(h.state).toBe("degraded"); + expect(h.reason).toBe("unexpected_silence"); + expect(h.since).toBe("2026-10-13T12:00:00.000Z"); + }); + + it("drops a stale silence flag as soon as fresh data has arrived", () => { + const h = resolveConnectionHealth( + input("fresh", { + lastDataAt: "2026-10-15T11:30:00.000Z", + silentSince: "2026-10-13T12:00:00.000Z", + }), + )!; + expect(h.state).toBe("ok"); + }); + + it("lets a reauth outrank the silence flag", () => { + const h = resolveConnectionHealth( + input("reauth_required", { + lastDataAt: "2026-10-13T12:00:00.000Z", + silentSince: "2026-10-13T12:00:00.000Z", + }), + )!; + expect(h.state).toBe("action_required"); + }); +}); + +describe("recent-call diagnostics", () => { + it("keeps only closed-shape codes", () => { + expect(safeErrorCode("401")).toBe("401"); + expect(safeErrorCode("invalid_grant")).toBe("invalid_grant"); + expect(safeErrorCode("Bearer abc def")).toBeNull(); + expect(safeErrorCode("https://host/api?token=x")).toBeNull(); + expect(safeErrorCode(null)).toBeNull(); + }); + + it("reads well-formed entries and drops anything carrying free text", () => { + const calls = readRecentCalls([ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T10:00:00Z", + errorCode: "503", + message: "upstream said something with a token", + }, + { + endpoint: "https://evil/?t=1", + status: "ok", + at: "2026-10-15T09:00:00Z", + errorCode: "token=secret value", + }, + { endpoint: "sleep", status: "exploded", at: "2026-10-15T08:00:00Z" }, + "not an object", + ]); + expect(calls).toEqual([ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T10:00:00.000Z", + errorCode: "503", + }, + { + endpoint: "sync", + status: "ok", + at: "2026-10-15T09:00:00.000Z", + errorCode: null, + }, + ]); + expect(JSON.stringify(calls)).not.toContain("token"); + }); + + it("caps the published list at ten", () => { + const many = Array.from({ length: 15 }, (_, i) => ({ + endpoint: "sync", + status: "ok", + at: new Date(NOW - i * HOUR).toISOString(), + errorCode: null, + })); + expect(readRecentCalls(many)).toHaveLength(10); + }); +}); diff --git a/src/lib/integrations/__tests__/watchdog-classify.test.ts b/src/lib/integrations/__tests__/watchdog-classify.test.ts new file mode 100644 index 000000000..0e2992e46 --- /dev/null +++ b/src/lib/integrations/__tests__/watchdog-classify.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from "vitest"; +import type { ConnectionHealth } from "../connection-health"; +import { + NORMAL_DELIVERY_MIN_DAYS, + SOURCE_PROFILES, + classifySilence, + currentIncident, + decideWatchTransition, + type WatchRow, +} from "../watchdog-classify"; +import { formatWatchdogMessage } from "../watchdog"; + +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const HOUR = 60 * 60 * 1000; +const ago = (hours: number) => new Date(NOW.getTime() - hours * HOUR); + +function health( + state: ConnectionHealth["state"], + reason: ConnectionHealth["reason"], + since: Date | null, +): ConnectionHealth { + return { + state, + reason, + since: since?.toISOString() ?? null, + action: null, + unavailable: [], + lastSuccessAt: null, + lastDataAt: null, + diagnostics: { + ledgerState: null, + failingLegs: [], + failingSince: null, + lastAttemptAt: null, + verdict: "fresh", + recentCalls: [], + }, + }; +} + +const openRow = (overrides: Partial = {}): WatchRow => ({ + silentSince: null, + incidentReason: "unexpected_silence", + incidentOpenedAt: ago(5), + alertedAt: ago(5), + ...overrides, +}); + +describe("classifySilence", () => { + const oura = SOURCE_PROFILES.oura; + const withings = SOURCE_PROFILES.withings; + + it("never flags an on-demand source for silence, however long and however regular", () => { + expect(withings.kind).toBe("on_demand"); + expect(SOURCE_PROFILES.strava.kind).toBe("on_demand"); + expect( + classifySilence({ + profile: withings, + lastDataAt: ago(24 * 30), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "clear" }); + }); + + it("is clear while a continuous source delivers inside its window", () => { + expect( + classifySilence({ + profile: oura, + lastDataAt: ago(20), + deliveredDays: null, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "clear" }); + }); + + it("asks for the history before flagging (the cheap hourly pass reads it lazily)", () => { + expect( + classifySilence({ + profile: oura, + lastDataAt: ago(30), + deliveredDays: null, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "needs_history" }); + }); + + it("flags a silent source that delivered on most of the previous 14 days", () => { + expect( + classifySilence({ + profile: oura, + lastDataAt: ago(30), + deliveredDays: NORMAL_DELIVERY_MIN_DAYS, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "flag", silentSince: ago(30) }); + }); + + it("does not flag a source its owner wears only now and then", () => { + expect( + classifySilence({ + profile: oura, + lastDataAt: ago(30), + deliveredDays: NORMAL_DELIVERY_MIN_DAYS - 1, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "not_expected" }); + }); + + it("keeps a standing flag on the hourly pass without re-reading history", () => { + expect( + classifySilence({ + profile: oura, + lastDataAt: ago(50), + deliveredDays: null, + currentSilentSince: ago(50), + now: NOW, + }), + ).toEqual({ kind: "flag", silentSince: ago(50) }); + }); + + it("drops a standing flag on the daily re-check once the history no longer says it delivers", () => { + expect( + classifySilence({ + profile: oura, + lastDataAt: ago(24 * 6), + deliveredDays: 8, + currentSilentSince: ago(24 * 6), + now: NOW, + }), + ).toEqual({ kind: "not_expected" }); + }); + + it("gives Apple Health a wider window than a wearable", () => { + const apple = SOURCE_PROFILES["apple-health"]; + expect( + classifySilence({ + profile: apple, + lastDataAt: ago(30), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "clear" }); + }); +}); + +describe("currentIncident", () => { + it("opens nothing for a degraded state younger than a day", () => { + expect( + currentIncident({ + health: health("degraded", "provider_errors", ago(5)), + silentSince: null, + ledgerAlerted: false, + now: NOW, + }), + ).toBeNull(); + }); + + it("opens an incident once degraded or action_required has lasted more than a day", () => { + expect( + currentIncident({ + health: health("action_required", "reauth_required", ago(25)), + silentSince: null, + ledgerAlerted: false, + now: NOW, + }), + ).toEqual({ reason: "reauth_required", since: ago(25) }); + }); + + it("follows the ledger's own page at once, so its recovery is reported too", () => { + expect( + currentIncident({ + health: health("degraded", "provider_errors", ago(3)), + silentSince: null, + ledgerAlerted: true, + now: NOW, + }), + ).toEqual({ reason: "provider_errors", since: ago(3) }); + }); + + it("opens a silence incident from the flag", () => { + expect( + currentIncident({ + health: health("ok", "data_arriving", null), + silentSince: ago(30), + ledgerAlerted: false, + now: NOW, + }), + ).toEqual({ reason: "unexpected_silence", since: ago(30) }); + }); + + it("has no incident for a limited connection", () => { + expect( + currentIncident({ + health: health("limited", "plan_limited", null), + silentSince: null, + ledgerAlerted: false, + now: NOW, + }), + ).toBeNull(); + }); +}); + +describe("decideWatchTransition", () => { + const incident = { reason: "unexpected_silence" as const, since: ago(30) }; + + it("opens and announces a new incident", () => { + expect( + decideWatchTransition({ row: null, incident, ledgerAlerted: false }), + ).toEqual({ kind: "open", ...incident }); + }); + + it("adopts an incident the ledger already paged about instead of paging again", () => { + expect( + decideWatchTransition({ row: null, incident, ledgerAlerted: true }), + ).toEqual({ kind: "adopt", ...incident }); + }); + + it("sends nothing while an announced incident stays open (no repeats)", () => { + expect( + decideWatchTransition({ + row: openRow(), + incident, + ledgerAlerted: false, + }), + ).toEqual({ kind: "keep", reason: "unexpected_silence", announce: false }); + }); + + it("treats a changed reason as the same incident", () => { + expect( + decideWatchTransition({ + row: openRow(), + incident: { reason: "reauth_required", since: ago(2) }, + ledgerAlerted: false, + }), + ).toEqual({ kind: "keep", reason: "reauth_required", announce: false }); + }); + + it("retries the announcement of an incident no channel accepted", () => { + expect( + decideWatchTransition({ + row: openRow({ alertedAt: null }), + incident, + ledgerAlerted: false, + }), + ).toEqual({ kind: "keep", reason: "unexpected_silence", announce: true }); + }); + + it("closes with one recovery note when the announced incident ends", () => { + expect( + decideWatchTransition({ + row: openRow(), + incident: null, + ledgerAlerted: false, + }), + ).toEqual({ + kind: "close", + notify: true, + previousReason: "unexpected_silence", + }); + }); + + it("closes quietly when the operator was never told it started", () => { + expect( + decideWatchTransition({ + row: openRow({ alertedAt: null }), + incident: null, + ledgerAlerted: false, + }), + ).toEqual({ + kind: "close", + notify: false, + previousReason: "unexpected_silence", + }); + }); + + it("does nothing when there was and is no incident", () => { + expect( + decideWatchTransition({ + row: null, + incident: null, + ledgerAlerted: false, + }), + ).toEqual({ kind: "none" }); + }); +}); + +describe("formatWatchdogMessage", () => { + it("names the provider, the account and the instant, in plain text", () => { + const m = formatWatchdogMessage({ + kind: "incident", + key: "oura", + subjectLabel: "someone@example.test", + reason: "unexpected_silence", + since: ago(30), + }); + expect(m.title).toBe("Oura needs attention for someone@example.test"); + expect(m.message).toContain("2026-10-14 06:00 UTC"); + expect(m.message).not.toMatch(/[—–·→]/); + }); + + it("says why a recovered incident ended", () => { + const m = formatWatchdogMessage({ + kind: "recovery", + key: "apple-health", + subjectLabel: "someone@example.test", + reason: "unexpected_silence", + since: null, + closedBecause: "disconnected", + }); + expect(m.title).toBe("Apple Health recovered for someone@example.test"); + expect(m.message).toContain("removed"); + }); +}); diff --git a/src/lib/integrations/watchdog.ts b/src/lib/integrations/watchdog.ts new file mode 100644 index 000000000..5fbe4f211 --- /dev/null +++ b/src/lib/integrations/watchdog.ts @@ -0,0 +1,430 @@ +/** + * The integration watchdog: notices a connection that has gone quiet or stayed + * broken, flags it for the connection health read, and tells the operator once. + * + * Runs on two schedules (`src/lib/jobs/integration-watchdog.ts`): + * + * hourly — cheap. One health load per account with a connection (the same + * loader the routes use), plus a 14-day history count only for a + * continuous source that is silent and not flagged yet. + * daily — the same pass, and it re-reads the history behind every standing + * silence flag, so a source its owner simply stopped using drops out + * once its own history no longer says it delivers. + * + * Judgement lives in `./watchdog-classify` (pure, unit-tested); this module + * gathers inputs and performs the side effects: one `IntegrationWatch` row per + * watched connection, one operator alert per incident through the existing + * admin path (`dispatchAdminSystemAlert`), one recovery note when it ends. + * + * Operator messages carry the provider, the account's email (as the ledger's + * own alerts do), a reason and an instant. Never a token, a value or an error + * message from the provider. + */ +import { prisma } from "@/lib/db"; +import type { MeasurementSource } from "@/generated/prisma/client"; +import { getEvent } from "@/lib/logging/context"; +import { dispatchAdminSystemAlert } from "./status"; +import { CONNECTION_KEYS, type ConnectionKey } from "./connection-health"; +import { + loadConnectionFacts, + type ConnectionFacts, +} from "./connection-health-load"; +import { INTEGRATION_MEASUREMENT_SOURCE } from "./metric-freshness"; +import { + HISTORY_DAYS, + SOURCE_PROFILES, + classifySilence, + currentIncident, + decideWatchTransition, + type IncidentReason, + type WatchRow, +} from "./watchdog-classify"; + +export type WatchdogMode = "hourly" | "daily"; + +export interface WatchdogSummary { + usersScanned: number; + usersFailed: number; + connectionsConsidered: number; + silencesFlagged: number; + incidentsOpened: number; + incidentsClosed: number; + notified: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +const LABELS: Record = { + withings: "Withings", + whoop: "WHOOP", + fitbit: "Fitbit", + "google-health": "Google Health", + polar: "Polar", + oura: "Oura", + strava: "Strava", + nightscout: "Nightscout", + "apple-health": "Apple Health", +}; + +function measurementSourceFor(key: ConnectionKey): MeasurementSource | null { + if (key === "apple-health") return "APPLE_HEALTH"; + return INTEGRATION_MEASUREMENT_SOURCE[key] ?? null; +} + +/** + * Distinct UTC days with data in the `HISTORY_DAYS` before the silence window. + * The window ends where silence starts to count, so the history being read is + * the one the silence is measured against. + */ +async function countDeliveredDays( + userId: string, + key: ConnectionKey, + now: Date, +): Promise { + const source = measurementSourceFor(key); + if (!source) return 0; + const to = new Date(now.getTime() - SOURCE_PROFILES[key].silenceWindowMs); + const from = new Date(to.getTime() - HISTORY_DAYS * DAY_MS); + const rows = await prisma.$queryRaw>` + SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::int AS "days" + FROM "measurements" m + WHERE m."user_id" = ${userId} + AND m."source" = ${source}::"measurement_source" + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${from} + AND m."measured_at" < ${to} + `; + return Number(rows[0]?.days ?? 0); +} + +/** + * Every account that has a connection the watchdog covers, or a watch row that + * still holds a flag or an incident (so a removed connection gets its incident + * closed). + */ +async function listWatchedUserIds(): Promise { + const [withings, whoop, fitbit, google, userRows, healthKit, watches] = + await Promise.all([ + prisma.withingsConnection.findMany({ select: { userId: true } }), + prisma.whoopConnection.findMany({ + where: { whoopUserId: { not: null } }, + select: { userId: true }, + }), + prisma.fitbitConnection.findMany({ select: { userId: true } }), + prisma.googleHealthConnection.findMany({ select: { userId: true } }), + prisma.user.findMany({ + where: { + OR: [ + { polarAccessTokenEncrypted: { not: null } }, + { ouraAccessTokenEncrypted: { not: null } }, + { stravaAccessTokenEncrypted: { not: null } }, + { nightscoutUrlEncrypted: { not: null } }, + { healthKitLastSyncedAt: { not: null } }, + ], + }, + select: { id: true }, + }), + prisma.healthKitTypeSync.findMany({ + distinct: ["userId"], + select: { userId: true }, + }), + prisma.integrationWatch.findMany({ + where: { + OR: [ + { silentSince: { not: null } }, + { incidentReason: { not: null } }, + ], + }, + select: { userId: true }, + }), + ]); + const ids = new Set(); + for (const r of [...withings, ...whoop, ...fitbit, ...google, ...healthKit]) { + ids.add(r.userId); + } + for (const r of userRows) ids.add(r.id); + for (const r of watches) ids.add(r.userId); + return [...ids].sort(); +} + +function formatInstant(value: Date): string { + return `${value.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +const INCIDENT_COPY: Record string> = { + unexpected_silence: (l) => + `${l} has delivered nothing although it normally delivers every day. The connection reports no error.`, + reauth_required: (l) => `${l} needs to be reconnected by the account owner.`, + paused_after_errors: (l) => + `${l} paused itself after a day of failing syncs. Resume or reconnect it in the integration settings.`, + provider_errors: (l) => + `${l} syncs have been failing for more than a day. HealthLog keeps retrying.`, + sync_stalled: (l) => `${l} sync has not run for more than a day.`, + no_new_data: (l) => `${l} has delivered no new data for a while.`, + // The remaining reasons belong to healthy states and never open an + // incident; the copy exists so the table stays total. + data_arriving: (l) => `${l} is delivering data.`, + first_sync_pending: (l) => `${l} is waiting for its first sync.`, + plan_limited: (l) => `${l} withholds some data types under its plan.`, + permission_limited: (l) => `${l} lacks permission for some data types.`, +}; + +/** + * Pure formatter for the two operator messages, exported for tests. Plain + * text: the dispatcher sends it to Telegram without a parse mode, and the only + * interpolations are a provider label, an email and server-built instants. + */ +export function formatWatchdogMessage(input: { + kind: "incident" | "recovery"; + key: ConnectionKey; + subjectLabel: string; + reason: string; + since: Date | null; + closedBecause?: "healthy" | "disconnected" | "not_expected"; +}): { title: string; message: string } { + const label = LABELS[input.key]; + if (input.kind === "incident") { + const copy = + INCIDENT_COPY[input.reason as IncidentReason]?.(label) ?? + `${label} needs attention.`; + const since = input.since ? ` Since ${formatInstant(input.since)}.` : ""; + return { + title: `${label} needs attention for ${input.subjectLabel}`, + message: `${copy}${since}`, + }; + } + const why = + input.closedBecause === "disconnected" + ? "The connection was removed." + : input.closedBecause === "not_expected" + ? "It has not delivered regularly for a while, so the watchdog no longer expects data from it." + : "Data is arriving again."; + return { + title: `${label} recovered for ${input.subjectLabel}`, + message: `${label} is fine again. ${why}`, + }; +} + +/** Run one watchdog pass over every watched account. */ +export async function runIntegrationWatchdog(opts: { + mode: WatchdogMode; + now?: Date; +}): Promise { + const now = opts.now ?? new Date(); + const summary: WatchdogSummary = { + usersScanned: 0, + usersFailed: 0, + connectionsConsidered: 0, + silencesFlagged: 0, + incidentsOpened: 0, + incidentsClosed: 0, + notified: 0, + }; + const userIds = await listWatchedUserIds(); + for (const userId of userIds) { + summary.usersScanned += 1; + try { + await watchUser(userId, opts.mode, now, summary); + } catch (err) { + // One account's failure must not stop the pass for everyone else. + summary.usersFailed += 1; + getEvent()?.addWarning( + `integration watchdog failed for one account: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + return summary; +} + +/** Watch every connection of one account. Exported for tests. */ +export async function watchUser( + userId: string, + mode: WatchdogMode, + now: Date, + summary: WatchdogSummary, +): Promise { + const [facts, rows, subject] = await Promise.all([ + loadConnectionFacts(userId, CONNECTION_KEYS, now), + prisma.integrationWatch.findMany({ where: { userId } }), + prisma.user.findUnique({ where: { id: userId }, select: { email: true } }), + ]); + const rowByKey = new Map(rows.map((r) => [r.integration, r])); + const subjectLabel = subject?.email ?? "an account"; + + for (const key of CONNECTION_KEYS) { + const fact = facts.get(key); + const row = rowByKey.get(key) ?? null; + if (!fact) continue; + if (!fact.connected && row === null) continue; + summary.connectionsConsidered += 1; + await watchConnection({ + userId, + key, + fact, + row, + mode, + now, + subjectLabel, + summary, + }); + } +} + +async function watchConnection(input: { + userId: string; + key: ConnectionKey; + fact: ConnectionFacts; + row: (WatchRow & { id: string }) | null; + mode: WatchdogMode; + now: Date; + subjectLabel: string; + summary: WatchdogSummary; +}): Promise { + const { userId, key, fact, row, mode, now, summary } = input; + const profile = SOURCE_PROFILES[key]; + + // ── Silence ──────────────────────────────────────────────────────── + let silentSince: Date | null = null; + let closedBecause: "healthy" | "disconnected" | "not_expected" = + fact.connected ? "healthy" : "disconnected"; + if (fact.connected) { + const base = { + profile, + lastDataAt: fact.lastDataAt, + currentSilentSince: row?.silentSince ?? null, + now, + }; + // The daily pass re-reads the history behind a standing flag. + const recheck = mode === "daily" && row?.silentSince != null; + let verdict = classifySilence({ + ...base, + deliveredDays: recheck + ? await countDeliveredDays(userId, key, now) + : null, + }); + if (verdict.kind === "needs_history") { + verdict = classifySilence({ + ...base, + deliveredDays: await countDeliveredDays(userId, key, now), + }); + } + if (verdict.kind === "flag") silentSince = verdict.silentSince; + if (verdict.kind === "not_expected") closedBecause = "not_expected"; + } + if (silentSince && !row?.silentSince) summary.silencesFlagged += 1; + + // ── Incident ─────────────────────────────────────────────────────── + const incident = currentIncident({ + health: fact.connected ? fact.healthWithoutSilence : null, + silentSince, + ledgerAlerted: fact.ledgerAlerted, + now, + }); + const transition = decideWatchTransition({ + row, + incident, + ledgerAlerted: fact.ledgerAlerted, + }); + + let incidentReason = row?.incidentReason ?? null; + let incidentOpenedAt = row?.incidentOpenedAt ?? null; + let alertedAt = row?.alertedAt ?? null; + + const announce = async (reason: string, since: Date | null) => { + const text = formatWatchdogMessage({ + kind: "incident", + key, + subjectLabel: input.subjectLabel, + reason, + since, + }); + const outcome = await dispatchAdminSystemAlert({ + subjectUserId: userId, + title: text.title, + message: text.message, + metadata: { integration: key, reason, watchdog: "incident" }, + }); + if (outcome.channelsAttempted > 0) { + alertedAt = now; + summary.notified += 1; + } + }; + + switch (transition.kind) { + case "none": + break; + case "open": + summary.incidentsOpened += 1; + incidentReason = transition.reason; + incidentOpenedAt = now; + await announce(transition.reason, transition.since); + break; + case "adopt": + // The ledger already paged about this streak; record it as announced so + // the operator gets the recovery note and no second page. + summary.incidentsOpened += 1; + incidentReason = transition.reason; + incidentOpenedAt = now; + alertedAt = now; + break; + case "keep": + incidentReason = transition.reason; + if (alertedAt === null && fact.ledgerAlerted) { + alertedAt = now; + } else if (transition.announce && incident) { + // Opened earlier while no admin channel accepted the alert. + await announce(transition.reason, incident.since); + } + break; + case "close": + summary.incidentsClosed += 1; + if (transition.notify) { + const text = formatWatchdogMessage({ + kind: "recovery", + key, + subjectLabel: input.subjectLabel, + reason: transition.previousReason, + since: null, + closedBecause, + }); + const outcome = await dispatchAdminSystemAlert({ + subjectUserId: userId, + title: text.title, + message: text.message, + metadata: { + integration: key, + reason: transition.previousReason, + watchdog: "recovery", + }, + }); + if (outcome.channelsAttempted > 0) summary.notified += 1; + } + incidentReason = null; + incidentOpenedAt = null; + alertedAt = null; + break; + } + + // Nothing to remember and nothing remembered: no row. + if (row === null && silentSince === null && incidentReason === null) return; + + await prisma.integrationWatch.upsert({ + where: { userId_integration: { userId, integration: key } }, + create: { + userId, + integration: key, + silentSince, + incidentReason, + incidentOpenedAt, + alertedAt, + checkedAt: now, + }, + update: { + silentSince, + incidentReason, + incidentOpenedAt, + alertedAt, + checkedAt: now, + }, + }); +} diff --git a/src/lib/jobs/integration-watchdog.ts b/src/lib/jobs/integration-watchdog.ts new file mode 100644 index 000000000..ce6373037 --- /dev/null +++ b/src/lib/jobs/integration-watchdog.ts @@ -0,0 +1,71 @@ +/** + * Queues, schedules and handler for the integration watchdog + * (`src/lib/integrations/watchdog.ts`). + * + * Two queues rather than one queue with two payloads, so each schedule shows + * up under its own name in the job ledger. Both passes take the same advisory + * lock: the daily pass is a superset of the hourly one, and two passes side by + * side could each open the same incident and page twice. + */ +import type { Job } from "pg-boss"; +import { withJobLock } from "@/lib/jobs/job-lock"; +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { + runIntegrationWatchdog, + type WatchdogMode, +} from "@/lib/integrations/watchdog"; + +export const INTEGRATION_WATCHDOG_HOURLY_QUEUE = "integration-watchdog-hourly"; +export const INTEGRATION_WATCHDOG_DAILY_QUEUE = "integration-watchdog-daily"; + +/** + * :47 is a free minute off every hourly sync tick (used: 0, 5, 8, 11, 13, 15, + * 17, 18, 20, 30, 35, 41, 50), late enough in the hour that the syncs before it + * have written what they fetched. + */ +export const INTEGRATION_WATCHDOG_HOURLY_CRON = "47 * * * *"; +/** Once a day, inside the maintenance window, off the hourly :47. */ +export const INTEGRATION_WATCHDOG_DAILY_CRON = "52 4 * * *"; + +const LOCK_KEY = "job:integration-watchdog:pass"; + +export interface IntegrationWatchdogPayload { + triggeredAt?: string; +} + +function handlerFor(mode: WatchdogMode) { + return async ( + jobs: Job[], + ): Promise => { + void jobs; + return withBackgroundEvent( + `job.integration_watchdog.${mode}`, + async (evt) => { + try { + const run = await withJobLock(LOCK_KEY, () => + runIntegrationWatchdog({ mode }), + ); + if (!run.ran) return jobDone({ already_running: 1 }); + const s = run.result; + evt.addMeta("integration_watchdog", s); + return jobDone({ + users_scanned: s.usersScanned, + users_failed: s.usersFailed, + considered: s.connectionsConsidered, + silences_flagged: s.silencesFlagged, + incidents_opened: s.incidentsOpened, + incidents_closed: s.incidentsClosed, + notified: s.notified, + }); + } catch (err) { + evt.addWarning(`integration watchdog failed: ${err}`); + return jobFailed("integration watchdog pass failed", err); + } + }, + ); + }; +} + +export const handleIntegrationWatchdogHourly = handlerFor("hourly"); +export const handleIntegrationWatchdogDaily = handlerFor("daily"); diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 8f8a8a2cc..f62c00e65 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -125,6 +125,9 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "hourly_rows_upserted", "import_staging_swept", "imported", + // v1.42.4 — integration watchdog: operator incidents opened / closed. + "incidents_closed", + "incidents_opened", "in_slot", "in_window", "insufficient", @@ -242,6 +245,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "sent", "served", "side_effects_migrated", + // v1.42.4 — integration watchdog: sources newly flagged as silent. + "silences_flagged", "single_user", "skipped", "skipped_already_dispatched", diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 2a9a88692..4efb8728d 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -295,6 +295,12 @@ export const QUEUE_RUNTIME: Readonly> = { "geo-backfill": short("At most 500 audit rows per pass."), "geolite2-fetch": short("One database download."), "tls-pin-monitor": short("One TLS handshake."), + "integration-watchdog-hourly": short( + "A few indexed reads per account with a connection and no provider call; a 14-day history count only for a silent source.", + ), + "integration-watchdog-daily": short( + "The hourly pass plus one 14-day history count per standing silence flag.", + ), "mood-reminder-cleanup": short("One retention DELETE."), "push-attempt-cleanup": short("One retention DELETE."), "arrival-reaction-cleanup": short("One retention DELETE."), diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts index f241b4b62..9e5b55bec 100644 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ b/src/lib/jobs/reminder/register-integration-sync.ts @@ -70,6 +70,15 @@ import { type IntegrationBackfillAdmissionPayload, } from "@/lib/jobs/integration-backfill-admission"; import { drainIntegrationBackfillAdmission } from "@/lib/jobs/integration-backfill-drain"; +import { + INTEGRATION_WATCHDOG_DAILY_CRON, + INTEGRATION_WATCHDOG_DAILY_QUEUE, + INTEGRATION_WATCHDOG_HOURLY_CRON, + INTEGRATION_WATCHDOG_HOURLY_QUEUE, + handleIntegrationWatchdogDaily, + handleIntegrationWatchdogHourly, + type IntegrationWatchdogPayload, +} from "@/lib/jobs/integration-watchdog"; import { workerLog } from "./shared"; import { jobDone } from "@/lib/jobs/job-outcome"; import { @@ -320,6 +329,10 @@ const allQueues = [ STRAVA_BACKFILL_QUEUE, // v1.30.x — daily sweep for the native OIDC handoff ledger. OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, + // v1.42.4 — the integration watchdog: an hourly cheap pass and a daily + // pass that re-reads the history behind standing silence flags. + INTEGRATION_WATCHDOG_HOURLY_QUEUE, + INTEGRATION_WATCHDOG_DAILY_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -377,6 +390,18 @@ const schedules: ScheduleEntry[] = [ [OURA_SYNC_QUEUE, OURA_SYNC_CRON], // v1.28.x — hourly Strava (:17) OAuth poll. [STRAVA_SYNC_QUEUE, STRAVA_SYNC_CRON], + // v1.42.4 — integration watchdog, hourly at :47 and daily at 04:52. The + // next tick is the retry: a missed pass only delays a flag by an hour. + [ + INTEGRATION_WATCHDOG_HOURLY_QUEUE, + INTEGRATION_WATCHDOG_HOURLY_CRON, + cronIsTheRetry, + ], + [ + INTEGRATION_WATCHDOG_DAILY_QUEUE, + INTEGRATION_WATCHDOG_DAILY_CRON, + cronIsTheRetry, + ], // v1.30.x — daily 03:28 Europe/Berlin prune for expired native OIDC handoffs. [ OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, @@ -725,6 +750,21 @@ export async function registerIntegrationSyncQueues( { localConcurrency: 1 }, handleStravaSync, ); + // v1.42.4 — integration watchdog. Single-flight per process; both queues + // also share one advisory lock, so the daily and hourly passes never run + // side by side on any replica. + await createAndWork( + boss, + INTEGRATION_WATCHDOG_HOURLY_QUEUE, + { localConcurrency: 1 }, + handleIntegrationWatchdogHourly, + ); + await createAndWork( + boss, + INTEGRATION_WATCHDOG_DAILY_QUEUE, + { localConcurrency: 1 }, + handleIntegrationWatchdogDaily, + ); // v1.28.x — self-converging Strava backfill. This source queue retains the // per-connection singleton and forwards admitted work to the shared drain. await createAndWork( diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index e4fc73d43..c4f51546c 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -43,6 +43,8 @@ const subsystemSurface = { "polar-sync": { audience: "account" }, "oura-sync": { audience: "account" }, "strava-sync": { audience: "account" }, + "integration-watchdog-hourly": { audience: "system" }, + "integration-watchdog-daily": { audience: "system" }, "strava-backfill": { audience: "system" }, "oidc-native-handoff-cleanup": { audience: "system" }, diff --git a/tests/integration/integration-watchdog.test.ts b/tests/integration/integration-watchdog.test.ts new file mode 100644 index 000000000..81c5ac97d --- /dev/null +++ b/tests/integration/integration-watchdog.test.ts @@ -0,0 +1,280 @@ +/** + * The integration watchdog and the connection health read against real + * Postgres: the 14-day history count, the one-alert-per-incident ledger, the + * recovery note, the on-demand exemption, the adoption of a page the sync + * ledger already sent, and the recent-call ring the diagnostics read. + * + * The dispatcher is mocked: what is asserted is how many operator messages + * the watchdog asks for, not their delivery. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: vi.fn().mockResolvedValue({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + }), +})); + +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { runIntegrationWatchdog } from "@/lib/integrations/watchdog"; +import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; +import { + recordSyncFailure, + recordSyncSuccess, +} from "@/lib/integrations/status"; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const USER = "user-watchdog-subject"; +const ADMIN = "user-watchdog-admin"; + +const prisma = () => getPrismaClient(); + +async function seedDailyReadings( + userId: string, + source: "OURA" | "WITHINGS", + days: number[], + newestHoursAgo: number, +) { + const rows = days.map((d) => ({ + userId, + type: "RESTING_HEART_RATE" as const, + value: 60, + unit: "bpm", + source, + measuredAt: new Date(NOW.getTime() - d * DAY - 6 * HOUR), + })); + rows.push({ + userId, + type: "RESTING_HEART_RATE" as const, + value: 60, + unit: "bpm", + source, + measuredAt: new Date(NOW.getTime() - newestHoursAgo * HOUR), + }); + await prisma().measurement.createMany({ data: rows }); +} + +function dispatchedTitles(): string[] { + return vi + .mocked(dispatchNotification) + .mock.calls.map((c) => String((c[0] as { title: string }).title)); +} + +beforeEach(async () => { + await truncateAllTables(prisma()); + await prisma().user.create({ + data: { + id: ADMIN, + username: "wd-admin", + email: "admin@example.test", + role: "ADMIN", + }, + }); + await prisma().user.create({ + data: { + id: USER, + username: "wd-subject", + email: "subject@example.test", + ouraAccessTokenEncrypted: "not-a-real-token", + }, + }); + vi.mocked(dispatchNotification).mockClear(); +}); + +describe("integration watchdog", () => { + it("flags a normally-daily wearable gone silent, alerts once, then sends one recovery note", async () => { + // Fourteen consecutive days of data before the window, newest 30 h ago. + await seedDailyReadings( + USER, + "OURA", + Array.from({ length: 13 }, (_, i) => i + 2), + 30, + ); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + try { + await recordSyncSuccess(USER, "oura"); + + const first = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + expect(first.silencesFlagged).toBe(1); + expect(first.incidentsOpened).toBe(1); + expect(dispatchedTitles()).toEqual([ + "Oura needs attention for subject@example.test", + ]); + + const watch = await prisma().integrationWatch.findUniqueOrThrow({ + where: { userId_integration: { userId: USER, integration: "oura" } }, + }); + expect(watch.incidentReason).toBe("unexpected_silence"); + expect(watch.silentSince).not.toBeNull(); + expect(watch.alertedAt).not.toBeNull(); + + const health = await loadConnectionHealth(USER, ["oura"]); + expect(health.oura?.state).toBe("degraded"); + expect(health.oura?.reason).toBe("unexpected_silence"); + + // Next hour, and the daily pass: still silent, nothing new is sent. + const later = new Date(NOW.getTime() + HOUR); + await runIntegrationWatchdog({ mode: "hourly", now: later }); + await runIntegrationWatchdog({ mode: "daily", now: later }); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + + // Data arrives again: the incident closes with one recovery note. + await prisma().measurement.create({ + data: { + userId: USER, + type: "RESTING_HEART_RATE", + value: 61, + unit: "bpm", + source: "OURA", + measuredAt: new Date(later.getTime() - 10 * 60 * 1000), + }, + }); + const recovered = await runIntegrationWatchdog({ + mode: "hourly", + now: new Date(later.getTime() + HOUR), + }); + expect(recovered.incidentsClosed).toBe(1); + expect(dispatchedTitles()).toEqual([ + "Oura needs attention for subject@example.test", + "Oura recovered for subject@example.test", + ]); + const cleared = await prisma().integrationWatch.findUniqueOrThrow({ + where: { userId_integration: { userId: USER, integration: "oura" } }, + }); + expect(cleared.silentSince).toBeNull(); + expect(cleared.incidentReason).toBeNull(); + + // Healthy and quiet: further passes send nothing. + await runIntegrationWatchdog({ + mode: "hourly", + now: new Date(later.getTime() + 2 * HOUR), + }); + expect(dispatchNotification).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("leaves a wearable alone when its owner wears it only now and then", async () => { + await seedDailyReadings(USER, "OURA", [3, 7, 11], 30); + await recordSyncSuccess(USER, "oura"); + const summary = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); + expect(summary.silencesFlagged).toBe(0); + expect(dispatchNotification).not.toHaveBeenCalled(); + expect(await prisma().integrationWatch.count()).toBe(0); + }); + + it("never alerts on silence for an on-demand source such as a scale", async () => { + await prisma().user.update({ + where: { id: USER }, + data: { ouraAccessTokenEncrypted: null }, + }); + await prisma().withingsConnection.create({ + data: { + userId: USER, + withingsUserId: "w-1", + accessToken: "x", + refreshToken: "y", + tokenExpiresAt: new Date(NOW.getTime() + DAY), + scope: "user.metrics,user.activity", + }, + }); + // Daily weigh-ins for two weeks, then nothing for three days. + await seedDailyReadings( + USER, + "WITHINGS", + Array.from({ length: 13 }, (_, i) => i + 4), + 72, + ); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + try { + await recordSyncSuccess(USER, "withings"); + const summary = await runIntegrationWatchdog({ mode: "daily", now: NOW }); + expect(summary.connectionsConsidered).toBe(1); + expect(summary.silencesFlagged).toBe(0); + expect(dispatchNotification).not.toHaveBeenCalled(); + const health = await loadConnectionHealth(USER, ["withings"]); + expect(health.withings?.state).toBe("ok"); + } finally { + vi.useRealTimers(); + } + }); + + it("adopts a failure streak the ledger already paged about and reports its end once", async () => { + await seedDailyReadings(USER, "OURA", [2], 5); + for (let i = 0; i < 3; i++) { + await recordSyncFailure({ + userId: USER, + integration: "oura", + kind: "transient", + message: "Oura error: 503 with a body that must never be published", + errorCode: "503", + }); + } + // The ledger's own 3-strike page. + expect(dispatchNotification).toHaveBeenCalledTimes(1); + + const now = new Date(); + const adopted = await runIntegrationWatchdog({ mode: "hourly", now }); + expect(adopted.incidentsOpened).toBe(1); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + + await recordSyncSuccess(USER, "oura"); + const closed = await runIntegrationWatchdog({ mode: "hourly", now }); + expect(closed.incidentsClosed).toBe(1); + expect(dispatchNotification).toHaveBeenCalledTimes(2); + expect(dispatchedTitles()[1]).toBe( + "Oura recovered for subject@example.test", + ); + }); +}); + +describe("recent-call ring", () => { + it("keeps the newest ten attempts, newest first, without any message text", async () => { + for (let i = 0; i < 7; i++) { + await recordSyncFailure({ + userId: USER, + integration: "oura", + kind: "transient", + message: `secret-bearing upstream body ${i}`, + errorCode: i === 6 ? "token=abc def" : "503", + leg: "sleep", + }); + } + for (let i = 0; i < 5; i++) { + await recordSyncSuccess(USER, "oura", { leg: "sleep" }); + } + const row = await prisma().integrationStatus.findUniqueOrThrow({ + where: { userId_integration: { userId: USER, integration: "oura" } }, + }); + const calls = row.recentCalls as Array>; + expect(calls).toHaveLength(10); + expect(calls.slice(0, 5).every((c) => c.status === "ok")).toBe(true); + expect(calls[5]).toMatchObject({ + endpoint: "sleep", + status: "transient", + errorCode: null, + }); + expect(calls[6]).toMatchObject({ errorCode: "503" }); + expect(JSON.stringify(calls)).not.toContain("secret"); + + const health = await loadConnectionHealth(USER, ["oura"]); + expect(health.oura?.diagnostics.recentCalls).toHaveLength(10); + expect(JSON.stringify(health.oura)).not.toContain(USER); + }); +}); From 8162afc0e1c37fc5b06863f1e26585104b68c350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:09:50 +0200 Subject: [PATCH 05/40] docs(api): publish the connection health state --- docs/api/openapi.yaml | 216 +++++++++++++++++++++++++ src/lib/openapi/routes/integrations.ts | 142 ++++++++++++++++ 2 files changed, 358 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c1dbd36c3..317c79c73 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -73461,6 +73461,12 @@ components: anyOf: - $ref: "#/components/schemas/AppleHealthSyncProgress" - type: "null" + health: + description: The unified Apple Health connection state. Null when the phone has never delivered anything (or the read + failed). Present on the GET read; omitted from the PATCH echo. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - entries - lastSyncedAt @@ -73598,6 +73604,171 @@ components: additionalProperties: false description: "Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they reach. Only what the server actually knows — no ETA, no device-side queue state." + ConnectionHealth: + type: object + properties: + state: + type: string + enum: + - ok + - limited + - degraded + - action_required + description: "`ok` data is arriving (or the first sync is still due). `limited` it works, but the types in `unavailable` + cannot be delivered (provider plan or missing permission). `degraded` temporary provider trouble or + unexpected silence; the server keeps retrying, `since` dates it. `action_required` the user has to do the + one thing in `action`." + reason: + type: string + enum: + - data_arriving + - first_sync_pending + - plan_limited + - permission_limited + - provider_errors + - sync_stalled + - no_new_data + - unexpected_silence + - reauth_required + - paused_after_errors + description: "Why the state is what it is. ok: `data_arriving`, `first_sync_pending`. limited: `plan_limited`, + `permission_limited`. degraded: `provider_errors`, `sync_stalled`, `no_new_data`, `unexpected_silence` (the + integration watchdog saw a source that normally delivers daily go quiet). action_required: + `reauth_required`, `paused_after_errors`. Pick the sentence from this code alone." + since: + 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" + description: When the current degraded / action_required condition started. Null for ok and limited. + action: + anyOf: + - type: string + enum: + - reconnect + - resume + - type: "null" + description: "The one action that resolves the state: `reconnect` (run the provider's connect flow again) or `resume` + (POST the provider's resume route). Also `reconnect` on a `limited` connection whose gap is a missing + permission. Null when nothing the user can do helps." + unavailable: + type: array + items: + type: object + properties: + item: + type: string + description: Provider data-type key, e.g. `spo2`, `vo2Max`, `activity`. + cause: + type: string + enum: + - plan + - permission + required: + - item + - cause + additionalProperties: false + description: Data types this connection cannot deliver. Present in every state, empty when nothing is withheld. + lastSuccessAt: + 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" + description: Last successful sync. For Apple Health, the last accepted batch. + lastDataAt: + 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" + description: "Newest data this source delivered: for polled providers when the newest reading or workout was taken, for + Apple Health when a sync last brought a new sample. Null when nothing has arrived." + diagnostics: + type: object + properties: + ledgerState: + anyOf: + - type: string + - type: "null" + description: The stored sync-ledger state. Null for Apple Health. + failingLegs: + type: array + items: + type: string + failingSince: + 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" + lastAttemptAt: + 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" + verdict: + type: string + description: The `SyncHealth` verdict the state was derived from. + recentCalls: + maxItems: 10 + type: array + items: + type: object + properties: + endpoint: + type: string + description: Sync leg name (`sleep`, `measures`, `sync`, `token_refresh`), or `batch:` for Apple Health. Never a + URL. + status: + type: string + enum: + - ok + - transient + - persistent + - reauth_required + at: + 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)))$ + errorCode: + anyOf: + - type: string + - type: "null" + description: Short error code (`401`, `invalid_grant`, `scope_missing`); anything not shaped like one is dropped. + required: + - endpoint + - status + - at + - errorCode + additionalProperties: false + description: The last ten sync attempts, newest first. + required: + - ledgerState + - failingLegs + - failingSince + - lastAttemptAt + - verdict + - recentCalls + additionalProperties: false + description: Redacted diagnostics for a bug report. Carries no token, no error message, no health value and no account + identifier, so a client may offer it for copying as is. + required: + - state + - reason + - since + - action + - unavailable + - lastSuccessAt + - lastDataAt + - diagnostics + additionalProperties: false + description: The one server-resolved health state of a connection (v1.42.4). It folds the ledger state, the liveness + verdict, plan and permission gaps, provider reauth flags and the integration watchdog's silence flag into four + states. Clients render it and never recompute it. The older health-shaped fields beside it stay for one release + after the clients read `health`, then go. HealthKitConfigPatchEnvelope: type: object properties: @@ -73933,6 +74104,12 @@ components: - type: "null" syncHealth: $ref: "#/components/schemas/SyncHealth" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -74129,6 +74306,12 @@ components: anyOf: - type: string - type: "null" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -74217,6 +74400,12 @@ components: hasActivityScope: description: Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity. type: boolean + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -74531,6 +74720,12 @@ components: anyOf: - type: string - type: "null" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -74694,6 +74889,12 @@ components: anyOf: - type: string - type: "null" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" needsReauth: description: "The stored refresh token was rejected with `invalid_grant` — Google expires it after 7 days while the OAuth client is in Testing publishing mode, and a user-revoked grant lands here too. Distinct from `parked` @@ -74929,6 +75130,12 @@ components: - type: "null" syncHealth: $ref: "#/components/schemas/SyncHealth" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -75468,6 +75675,14 @@ components: allowPrivateHost: description: Nightscout only. type: boolean + health: + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" + description: The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, + `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, + `planUnavailable` and `scopeReconnect` on this entry, which go once the settings surface renders `health` + (no other client reads this envelope). required: - integration - state @@ -75479,6 +75694,7 @@ components: - metricFreshness - connected - configured + - health additionalProperties: false description: "One provider's entry in the consolidated envelope: the ledger core and the liveness verdict, which every entry carries, plus the extras that provider has. The optional fields are not optional per request — each is diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index eb8be83ed..9919a20c4 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -31,6 +31,12 @@ * come from `src/lib/validations/*` so the wire contract stays single-source. */ import { z } from "zod/v4"; +import { + CONNECTION_HEALTH_ACTIONS, + CONNECTION_HEALTH_REASONS, + CONNECTION_HEALTH_STATES, + RECENT_CALL_STATUSES, +} from "@/lib/integrations/connection-health"; import type { ZodOpenApiObject } from "zod-openapi"; import { fitbitCredentialsSchema } from "@/lib/validations/fitbit"; import { googleHealthCredentialsSchema } from "@/lib/validations/google-health"; @@ -97,6 +103,101 @@ const syncHealth = z "Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'.", }); +// Mirrors `ConnectionHealth` in `src/lib/integrations/connection-health.ts`: +// the one server-resolved state per connection every client renders. +const connectionHealth = z + .object({ + state: z + .enum(CONNECTION_HEALTH_STATES) + .describe( + "`ok` data is arriving (or the first sync is still due). `limited` it works, but the types in `unavailable` cannot be delivered (provider plan or missing permission). `degraded` temporary provider trouble or unexpected silence; the server keeps retrying, `since` dates it. `action_required` the user has to do the one thing in `action`.", + ), + reason: z + .enum(CONNECTION_HEALTH_REASONS) + .describe( + "Why the state is what it is. ok: `data_arriving`, `first_sync_pending`. limited: `plan_limited`, `permission_limited`. degraded: `provider_errors`, `sync_stalled`, `no_new_data`, `unexpected_silence` (the integration watchdog saw a source that normally delivers daily go quiet). action_required: `reauth_required`, `paused_after_errors`. Pick the sentence from this code alone.", + ), + since: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "When the current degraded / action_required condition started. Null for ok and limited.", + ), + action: z + .enum(CONNECTION_HEALTH_ACTIONS) + .nullable() + .describe( + "The one action that resolves the state: `reconnect` (run the provider's connect flow again) or `resume` (POST the provider's resume route). Also `reconnect` on a `limited` connection whose gap is a missing permission. Null when nothing the user can do helps.", + ), + unavailable: z + .array( + z.object({ + item: z + .string() + .describe( + "Provider data-type key, e.g. `spo2`, `vo2Max`, `activity`.", + ), + cause: z.enum(["plan", "permission"]), + }), + ) + .describe( + "Data types this connection cannot deliver. Present in every state, empty when nothing is withheld.", + ), + lastSuccessAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Last successful sync. For Apple Health, the last accepted batch.", + ), + lastDataAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Newest data this source delivered: for polled providers when the newest reading or workout was taken, for Apple Health when a sync last brought a new sample. Null when nothing has arrived.", + ), + diagnostics: z + .object({ + ledgerState: z + .string() + .nullable() + .describe("The stored sync-ledger state. Null for Apple Health."), + failingLegs: z.array(z.string()), + failingSince: z.iso.datetime({ offset: true }).nullable(), + lastAttemptAt: z.iso.datetime({ offset: true }).nullable(), + verdict: z + .string() + .describe("The `SyncHealth` verdict the state was derived from."), + recentCalls: z + .array( + z.object({ + endpoint: z + .string() + .describe( + "Sync leg name (`sleep`, `measures`, `sync`, `token_refresh`), or `batch:` for Apple Health. Never a URL.", + ), + status: z.enum(RECENT_CALL_STATUSES), + at: z.iso.datetime({ offset: true }), + errorCode: z + .string() + .nullable() + .describe( + "Short error code (`401`, `invalid_grant`, `scope_missing`); anything not shaped like one is dropped.", + ), + }), + ) + .max(10) + .describe("The last ten sync attempts, newest first."), + }) + .describe( + "Redacted diagnostics for a bug report. Carries no token, no error message, no health value and no account identifier, so a client may offer it for copying as is.", + ), + }) + .meta({ + id: "ConnectionHealth", + description: + "The one server-resolved health state of a connection (v1.42.4). It folds the ledger state, the liveness verdict, plan and permission gaps, provider reauth flags and the integration watchdog's silence flag into four states. Clients render it and never recompute it. The older health-shaped fields beside it stay for one release after the clients read `health`, then go.", + }); + const metricFreshnessEntry = z .object({ type: z @@ -212,6 +313,12 @@ const healthKitConfigResponse = z .describe( "Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified Apple Health connection state. Null when the phone has never delivered anything (or the read failed). Present on the GET read; omitted from the PATCH echo.", + ), }) .meta({ id: "HealthKitConfigResponse", @@ -405,6 +512,12 @@ const nightscoutStatusResponse = z "Last recorded failure message, decrypted from the ledger. Null when the last attempt succeeded or none has run.", ), syncHealth: syncHealth.optional(), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "NightscoutStatusResponse", @@ -483,6 +596,12 @@ const whoopStatusResponse = z .describe( "Space-separated OAuth scope string granted by WHOOP. Null on a legacy connection that predates scope tracking.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "WhoopStatusResponse", @@ -529,6 +648,12 @@ const withingsStatusResponse = z .describe( "Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "WithingsStatusResponse", @@ -810,6 +935,12 @@ function connectionStatusShape() { .describe( "OAuth scope string granted at authorisation. Null on a legacy connection that predates scope tracking.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }; } @@ -863,6 +994,12 @@ const oauthProviderStatusResponse = z lastAttemptAt: z.iso.datetime({ offset: true }).nullable().optional(), lastError: z.string().nullable().optional(), syncHealth: syncHealth.optional(), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "OAuthProviderStatusResponse", @@ -947,6 +1084,11 @@ const integrationEnvelopeEntry = z ), hasToken: z.boolean().optional().describe("Nightscout only."), allowPrivateHost: z.boolean().optional().describe("Nightscout only."), + health: connectionHealth + .nullable() + .describe( + "The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry, which go once the settings surface renders `health` (no other client reads this envelope).", + ), }) .meta({ id: "IntegrationStatusEntry", From 4a49784464b52d62f247cb12415603bff841e037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:11:57 +0200 Subject: [PATCH 06/40] chore(demo): file the demo medications under categories, let the evening supplement record itself, and store two lab readings converted from SI units --- scripts/seed-demo.ts | 82 +++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 7c606cb91..c37cf6089 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -24,7 +24,10 @@ * - ~3-4 workouts/week (running, strength, cycling) with per-workout HR * samples and per-workout strain * - 3 medications with schedules and ~90 days of intake history at high - * compliance, with today scheduled on-track (taken or not-yet-due) + * compliance, with today scheduled on-track (taken or not-yet-due); each + * filed under a category, the evening supplement recording itself + * - Two lab readings the report printed in SI units, stored converted with + * the printed value kept beside them * - 90 days of mood entries * - Six mental-wellbeing screeners (WHO-5, PHQ-9, GAD-7) spread over the * window, item answers encrypted the way the check-in route writes them @@ -1298,12 +1301,15 @@ async function seed() { [sched2Id, med2Id], ); - // Medication 3: Magnesium (evening) + // Medication 3: Magnesium (evening). A supplement taken reliably every + // night, so it records itself: `auto_mark_taken` is on from its first day + // and the intake-auto-take job fills each evening slot once 20:00 has + // passed. Its history below is written the way that job writes it. const med3Id = cuid(); await client.query( - `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, created_at, updated_at) - VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $4, $4)`, - [med3Id, userId, formatDate(daysAgo(60)), daysAgo(60)], + `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, auto_mark_taken, auto_mark_taken_since, created_at, updated_at) + VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, true, $4, $5, $5)`, + [med3Id, userId, formatDate(daysAgo(60)), daysAgo(60), daysAgo(60)], ); const sched3Id = cuid(); await client.query( @@ -1312,6 +1318,20 @@ async function seed() { [sched3Id, med3Id], ); + // The category each medication is filed under, so the list groups the + // blood-pressure prescription apart from the two supplements. + for (const [medicationId, category] of [ + [med1Id, "BLOOD_PRESSURE"], + [med2Id, "VITAMIN"], + [med3Id, "SUPPLEMENT"], + ] as const) { + await client.query( + `INSERT INTO medication_categories (medication_id, category, updated_at) + VALUES ($1, $2, NOW())`, + [medicationId, category], + ); + } + // ── Medication Intake Events ────────────── // High compliance with no overdue doses. Historical days (i < days) carry // a taken/skip row at ~95%+ adherence. TODAY is handled separately below @@ -1371,26 +1391,14 @@ async function seed() { ); } - // Magnesium: only last 60 days, 94% compliance + // Magnesium: only the last 60 days, recorded automatically at the + // slot instant, the shape the intake-auto-take job writes. if (i >= 30) { - const takeMed3 = Math.random() > 0.06; - if (takeMed3) { - const takenTime = new Date(eveningSlotFor); - takenTime.setMinutes( - takenTime.getMinutes() + Math.floor(Math.random() * 60) + 1, - ); - await client.query( - `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`, - [cuid(), userId, med3Id, eveningSlotFor, takenTime], - ); - } else { - await client.query( - `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at) - VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`, - [cuid(), userId, med3Id, eveningSlotFor], - ); - } + await client.query( + `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, auto_recorded, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, $4, false, true, 'REMINDER', $4, $4)`, + [cuid(), userId, med3Id, eveningSlotFor], + ); } } @@ -1535,6 +1543,12 @@ async function seed() { unit: string; low: number; high: number; + /** + * What the report printed when it used another unit than the marker's. + * The stored value is the conversion; the printed pair is kept beside + * it, as the lab write path does, so the list shows "converted from". + */ + printed?: { value: number; unit: string }; }; type QualLab = { analyte: string; valueText: string; unit: string }; @@ -1551,14 +1565,24 @@ async function seed() { { analyte: "HDL", value: 58, unit: "mg/dL", low: 40, high: 100 }, { analyte: "Triglycerides", value: 92, unit: "mg/dL", low: 0, high: 150 }, { analyte: "HbA1c", value: 5.2, unit: "%", low: 4.0, high: 5.6 }, + // Both printed in SI units by the lab: 4.94 mmol/L is 89 mg/dL + // (factor 18.0156) and 104.83 nmol/L is 42 ng/mL (factor 2.496). { analyte: "Fasting Glucose", - value: 89, + value: 4.94 * 18.0156, unit: "mg/dL", low: 70, high: 99, + printed: { value: 4.94, unit: "mmol/L" }, + }, + { + analyte: "Vitamin D", + value: 104.83 / 2.496, + unit: "ng/mL", + low: 30, + high: 100, + printed: { value: 104.83, unit: "nmol/L" }, }, - { analyte: "Vitamin D", value: 42, unit: "ng/mL", low: 30, high: 100 }, { analyte: "TSH", value: 1.8, unit: "mIU/L", low: 0.4, high: 4.0 }, { analyte: "Ferritin", value: 120, unit: "ng/mL", low: 30, high: 400 }, { analyte: "CRP", value: 0.8, unit: "mg/L", low: 0, high: 3.0 }, @@ -1602,8 +1626,8 @@ async function seed() { const insertQuant = async (panel: string, lab: QuantLab, takenAt: Date) => { await client.query( - `INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, taken_at, source, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8, $9, 'MANUAL', NOW(), NOW())`, + `INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, source_value, source_unit, taken_at, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8, $9, $10, $11, 'MANUAL', NOW(), NOW())`, [ cuid(), userId, @@ -1613,6 +1637,8 @@ async function seed() { lab.unit, lab.low, lab.high, + lab.printed?.value ?? null, + lab.printed?.unit ?? null, takenAt, ], ); From f2646bc186ef35c99a57b66f511652c123ccbef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:15:06 +0200 Subject: [PATCH 07/40] test(integrations): cover the health field on the status routes --- .../status/__tests__/route.test.ts | 40 +++++++++++++++++++ .../api/oura/status/__tests__/route.test.ts | 3 ++ .../api/polar/status/__tests__/route.test.ts | 3 ++ .../api/whoop/status/__tests__/route.test.ts | 3 ++ 4 files changed, 49 insertions(+) diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 25b08cc78..67daaed0a 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -5,6 +5,9 @@ vi.mock("@/lib/api-handler", () => ({ requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), })); +vi.mock("@/lib/integrations/connection-health-load", () => ({ + loadConnectionHealth: vi.fn().mockResolvedValue({}), +})); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, @@ -444,3 +447,40 @@ describe("/api/integrations/status — what a provider withholds by contract", ( ).toEqual([]); }); }); + +describe("/api/integrations/status — the unified connection health", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } + }); + + it("puts each provider's resolved health on its own entry, null where not connected", async () => { + const { loadConnectionHealth } = + await import("@/lib/integrations/connection-health-load"); + const ouraHealth = { state: "limited", reason: "permission_limited" }; + vi.mocked(loadConnectionHealth).mockResolvedValueOnce({ + withings: null, + whoop: null, + fitbit: null, + "google-health": null, + polar: null, + oura: ouraHealth, + strava: null, + nightscout: null, + "apple-health": null, + } as never); + userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" }); + + const entries = (await fetchEntries()) as Array< + Entry & { health?: unknown } + >; + expect(entries.find((e) => e.integration === "oura")?.health).toEqual( + ouraHealth, + ); + for (const entry of entries.filter((e) => e.integration !== "oura")) { + expect(entry.health).toBeNull(); + } + }); +}); diff --git a/src/app/api/oura/status/__tests__/route.test.ts b/src/app/api/oura/status/__tests__/route.test.ts index dc73142d0..6dc845f78 100644 --- a/src/app/api/oura/status/__tests__/route.test.ts +++ b/src/app/api/oura/status/__tests__/route.test.ts @@ -5,6 +5,9 @@ vi.mock("@/lib/api-handler", () => ({ requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), })); +vi.mock("@/lib/integrations/connection-health-load", () => ({ + loadOneConnectionHealth: vi.fn().mockResolvedValue(null), +})); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() } }, })); diff --git a/src/app/api/polar/status/__tests__/route.test.ts b/src/app/api/polar/status/__tests__/route.test.ts index 7a7d30162..f9b4adbfd 100644 --- a/src/app/api/polar/status/__tests__/route.test.ts +++ b/src/app/api/polar/status/__tests__/route.test.ts @@ -5,6 +5,9 @@ vi.mock("@/lib/api-handler", () => ({ requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), })); +vi.mock("@/lib/integrations/connection-health-load", () => ({ + loadOneConnectionHealth: vi.fn().mockResolvedValue(null), +})); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() } }, })); diff --git a/src/app/api/whoop/status/__tests__/route.test.ts b/src/app/api/whoop/status/__tests__/route.test.ts index 3744084de..80d07e6cf 100644 --- a/src/app/api/whoop/status/__tests__/route.test.ts +++ b/src/app/api/whoop/status/__tests__/route.test.ts @@ -5,6 +5,9 @@ vi.mock("@/lib/api-handler", () => ({ requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), })); +vi.mock("@/lib/integrations/connection-health-load", () => ({ + loadOneConnectionHealth: vi.fn().mockResolvedValue(null), +})); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, From 73c4dda2247a858eab7382e05abf968eba7f9dc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:24:40 +0200 Subject: [PATCH 08/40] fix(visits): reach every lab result, condition, document and visit from the link pickers The visit form's lab picker offered the newest 200 results, the condition picker the newest 100 conditions, the document pickers the newest thousand documents and the document sheet's visit picker the newest 100 visits. On a long record everything older could not be linked. Each picker now opens on the records around the date of the thing being linked, pages older and newer from there, and searches through the list route itself. Records already linked always show, from the labels the record's own read carries and from what was picked in the sheet. The lab list takes a q filter over analyte and panel, the condition list from/to/q/offset/sortDir, and the visit list an offset. --- docs/api/openapi.yaml | 43 +++ messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- src/app/api/encounters/route.ts | 8 +- src/app/api/illness/episodes/route.ts | 20 +- src/app/api/labs/route.ts | 8 +- .../__tests__/document-record-links.test.tsx | 26 +- .../documents/document-record-links.tsx | 90 ++++- .../encounter-link-picker-limits.test.ts | 32 -- src/components/encounters/encounter-form.tsx | 10 + .../encounters/encounter-link-pickers.tsx | 206 ++++++++---- .../entity-link-picker-paging.test.tsx | 199 +++++++++++ .../links/__tests__/picker-requests.test.ts | 122 +++++++ .../__tests__/vault-document-options.test.ts | 41 ++- src/components/links/entity-link-picker.tsx | 220 ++++++++++++- src/components/links/picker-requests.ts | 96 ++++++ src/components/links/use-link-picker-pages.ts | 235 +++++++++++++ .../links/vault-document-options.ts | 109 +++--- .../vaccination-document-picker.tsx | 35 +- .../vaccinations/vaccination-form.tsx | 7 + .../vaccinations/vaccination-sheet.tsx | 1 + .../__tests__/picker-list-keys.test.ts | 38 ++- src/lib/query-keys/documents.ts | 22 +- src/lib/query-keys/encounters.ts | 7 + src/lib/query-keys/illness.ts | 12 +- src/lib/query-keys/labs.ts | 11 +- src/lib/validations/encounters.ts | 5 + src/lib/validations/illness.ts | 11 + src/lib/validations/labs.ts | 5 + .../visit-link-picker-long-history.test.ts | 311 ++++++++++++++++++ 35 files changed, 1714 insertions(+), 251 deletions(-) delete mode 100644 src/components/encounters/__tests__/encounter-link-picker-limits.test.ts create mode 100644 src/components/links/__tests__/entity-link-picker-paging.test.tsx create mode 100644 src/components/links/__tests__/picker-requests.test.ts create mode 100644 src/components/links/picker-requests.ts create mode 100644 src/components/links/use-link-picker-pages.ts create mode 100644 tests/integration/visit-link-picker-long-history.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c1dbd36c3..e87d57ba5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14833,6 +14833,12 @@ paths: type: string minLength: 1 maxLength: 120 + - in: query + name: q + schema: + type: string + minLength: 1 + maxLength: 120 - in: query name: from schema: @@ -15372,6 +15378,12 @@ paths: type: integer minimum: 1 maximum: 200 + - in: query + name: offset + schema: + type: integer + minimum: 0 + maximum: 100000 responses: "200": description: The caller's visits. @@ -16315,6 +16327,37 @@ paths: const: "true" - type: string const: "false" + - in: query + name: offset + schema: + type: integer + minimum: 0 + maximum: 100000 + - in: query + name: from + schema: + 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)))$ + - in: query + name: to + schema: + 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)))$ + - in: query + name: sortDir + schema: + type: string + enum: + - asc + - desc + - in: query + name: q + schema: + type: string + minLength: 1 + maxLength: 120 responses: "200": description: The caller's illness episodes. diff --git a/messages/de.json b/messages/de.json index 6a814002e..e7a8a1dd0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12174,7 +12174,10 @@ "suggested": "Um dieses Datum", "loadError": "Die Liste konnte nicht geladen werden.", "removeNamed": "{name} entfernen", - "removed": "Verknüpfung mit {name} gelöst" + "removed": "Verknüpfung mit {name} gelöst", + "linked": "Verknüpft", + "loadNewer": "Neuere laden", + "loadOlder": "Ältere laden" } }, "bodySites": { diff --git a/messages/en.json b/messages/en.json index 356521583..11a517a80 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12174,7 +12174,10 @@ "suggested": "Around this date", "loadError": "The list could not be loaded.", "removeNamed": "Remove {name}", - "removed": "Unlinked {name}" + "removed": "Unlinked {name}", + "linked": "Linked", + "loadNewer": "Load newer", + "loadOlder": "Load older" } }, "bodySites": { diff --git a/messages/es.json b/messages/es.json index dfa682e77..43c808b32 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12174,7 +12174,10 @@ "suggested": "En torno a esta fecha", "loadError": "No se pudo cargar la lista.", "removeNamed": "Quitar {name}", - "removed": "Se desvinculó {name}" + "removed": "Se desvinculó {name}", + "linked": "Vinculados", + "loadNewer": "Cargar más recientes", + "loadOlder": "Cargar anteriores" } }, "bodySites": { diff --git a/messages/fr.json b/messages/fr.json index 587297fd3..3f3de9ecd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12174,7 +12174,10 @@ "suggested": "Autour de cette date", "loadError": "Impossible de charger la liste.", "removeNamed": "Retirer {name}", - "removed": "{name} dissocié" + "removed": "{name} dissocié", + "linked": "Liés", + "loadNewer": "Charger les plus récents", + "loadOlder": "Charger les plus anciens" } }, "bodySites": { diff --git a/messages/it.json b/messages/it.json index 5302c2727..4853184ed 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12174,7 +12174,10 @@ "suggested": "Intorno a questa data", "loadError": "Impossibile caricare l’elenco.", "removeNamed": "Rimuovi {name}", - "removed": "Collegamento a {name} rimosso" + "removed": "Collegamento a {name} rimosso", + "linked": "Collegati", + "loadNewer": "Carica più recenti", + "loadOlder": "Carica meno recenti" } }, "bodySites": { diff --git a/messages/ko.json b/messages/ko.json index fe1370a61..122c25d92 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12174,7 +12174,10 @@ "suggested": "이 날짜 전후", "loadError": "목록을 불러오지 못했습니다.", "removeNamed": "{name} 제거", - "removed": "{name} 연결 해제됨" + "removed": "{name} 연결 해제됨", + "linked": "연결됨", + "loadNewer": "최근 항목 더 보기", + "loadOlder": "이전 항목 더 보기" } }, "bodySites": { diff --git a/messages/pl.json b/messages/pl.json index 7a3e8b972..5907e3c21 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12174,7 +12174,10 @@ "suggested": "Wokół tej daty", "loadError": "Nie udało się wczytać listy.", "removeNamed": "Usuń {name}", - "removed": "Odłączono: {name}" + "removed": "Odłączono: {name}", + "linked": "Połączone", + "loadNewer": "Wczytaj nowsze", + "loadOlder": "Wczytaj starsze" } }, "bodySites": { diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 43151980d..ef90cc80b 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -63,6 +63,7 @@ export const GET = apiHandler(async (request: NextRequest) => { practitionerId: params.get("practitionerId") ?? undefined, episodeId: params.get("episodeId") ?? undefined, limit: params.get("limit") ?? undefined, + offset: params.get("offset") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { @@ -72,6 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const query = parsed.data; const take = query.limit ?? 100; + const skip = query.offset ?? 0; const now = new Date(); const where = { @@ -112,8 +114,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // `gt` and `gte` are different keys, so the caller's lower bound and this // arm's floor both survive the spread; only the `lte` pair collided. where: { ...where, occurredAt: { ...where.occurredAt, gt: now } }, - orderBy: { occurredAt: "asc" }, + orderBy: [{ occurredAt: "asc" }, { id: "asc" }], take, + skip, include: ENCOUNTER_INCLUDE, }), prisma.encounter.findMany({ @@ -121,8 +124,9 @@ export const GET = apiHandler(async (request: NextRequest) => { ...where, occurredAt: { ...where.occurredAt, lte: pastCeiling }, }, - orderBy: { occurredAt: "desc" }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], take, + skip, include: ENCOUNTER_INCLUDE, }), ]); diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 15425425f..255e0f8c0 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -43,6 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => { const parsed = illnessEpisodeListQuerySchema.safeParse({ limit: params.get("limit") ?? undefined, includeResolved: params.get("includeResolved") ?? undefined, + offset: params.get("offset") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + sortDir: params.get("sortDir") ?? undefined, + q: params.get("q") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { @@ -50,15 +55,28 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } + const { from, to, q, offset, sortDir } = parsed.data; const limit = parsed.data.limit ?? 50; + const order = sortDir ?? "desc"; const rows = await prisma.illnessEpisode.findMany({ where: { userId: user.id, deletedAt: null, ...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}), + ...(from || to + ? { + onsetAt: { + ...(from ? { gte: new Date(from) } : {}), + ...(to ? { lte: new Date(to) } : {}), + }, + } + : {}), + ...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}), }, - orderBy: { onsetAt: "desc" }, + // `id` breaks ties so offset paging never repeats or drops a row. + orderBy: [{ onsetAt: order }, { id: order }], take: limit, + skip: offset ?? 0, }); annotate({ diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index e18282a76..7d1b8857e 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -85,7 +85,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } = + const { biomarkerId, analyte, panel, q, from, to, limit, offset, sortDir } = parsed.data; const where = { @@ -94,6 +94,12 @@ export const GET = apiHandler(async (request: NextRequest) => { ...(biomarkerId && { biomarkerId }), ...(analyte && { analyte }), ...(panel && { panel }), + ...(q && { + OR: [ + { analyte: { contains: q, mode: "insensitive" as const } }, + { panel: { contains: q, mode: "insensitive" as const } }, + ], + }), ...(from || to ? { takenAt: { diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index e83374fcf..209e10413 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -4,12 +4,26 @@ import { renderToStaticMarkup } from "react-dom/server"; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { modules: { vaccinations: true } } }), })); -vi.mock("@/hooks/use-encounters", () => ({ - useEncounters: () => ({ - data: { upcoming: [], past: [] }, - isPending: false, - isError: false, - refetch: vi.fn(), +vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/components/links/use-link-picker-pages") + >()), + // The visit picker's pages: none loaded, nothing more to page. + useLinkPickerPages: () => ({ + items: [], + pending: false, + error: false, + retry: vi.fn(), + term: "", + setTerm: vi.fn(), + searchPending: false, + filterLocally: true, + hasOlder: false, + hasNewer: false, + loadingOlder: false, + loadingNewer: false, + loadOlder: vi.fn(), + loadNewer: vi.fn(), }), })); vi.mock("@/components/vaccinations/use-vaccinations", () => ({ diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index b91b7fb0d..a80281ef9 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -23,18 +23,31 @@ * reach the doses (`vaccinationLinks: null`) shows no dose block rather than * an empty one that would claim the page records none. */ -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { CalendarDays, Syringe } from "lucide-react"; -import { EntityLinkPicker } from "@/components/links/entity-link-picker"; +import { + EntityLinkPicker, + serverPagingFrom, +} from "@/components/links/entity-link-picker"; import type { EntityLinkOption } from "@/components/links/entity-link-picker"; import { isNearAnchor } from "@/components/links/vault-document-options"; +import { encounterPickerUrl } from "@/components/links/picker-requests"; +import { + PICKER_PAGE_SIZE, + pickerPivot, + useLinkPickerPages, + type PickerPage, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; import { encounterKindText } from "@/components/encounters/encounter-labels"; import { useVaccinations } from "@/components/vaccinations/use-vaccinations"; -import { useEncounters } from "@/hooks/use-encounters"; import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; import type { EncounterKind } from "@/generated/prisma/client"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; @@ -75,6 +88,25 @@ export function recordOptions( return [...near, ...rest]; } +/** + * One picker page of visits. The route answers in two arms, each paged by the + * same offset; another page exists while either arm came back full. + */ +export async function fetchEncounterPage( + request: PickerPageRequest, +): Promise> { + const list = await apiGet(encounterPickerUrl(request)); + const full = + list.upcoming.length >= PICKER_PAGE_SIZE || + list.past.length >= PICKER_PAGE_SIZE; + return { + items: [...list.upcoming, ...list.past], + next: full + ? String(Number(request.cursor ?? "0") + PICKER_PAGE_SIZE) + : null, + }; +} + /** Where a visit opens: its sheet on the checkups page. */ function visitHref(encounterId: string): string { return `/checkups?visit=${encodeURIComponent(encounterId)}`; @@ -132,7 +164,16 @@ export function DocumentRecordLinks({ const vaccinationsOn = user?.modules?.vaccinations !== false; const anchor = doc.documentDate ?? doc.reportDate; - const encounters = useEncounters(canManage); + // The visits around the document's date, paged older and newer from + // there. The visit route has no text search (its free text is ciphertext + // at rest), so the term filters what is loaded. + const encounters = useLinkPickerPages({ + enabled: canManage, + pivot: pickerPivot(anchor), + queryKey: queryKeys.encounterPicker, + fetchPage: fetchEncounterPage, + serverSearch: false, + }); const vaccinations = useVaccinations( null, canManage && vaccinationsOn && doc.vaccinationLinks !== null, @@ -150,16 +191,16 @@ export function DocumentRecordLinks({ }; const visitOptions = recordOptions( - [ - ...(encounters.data?.upcoming ?? []), - ...(encounters.data?.past ?? []), - ].map((visit) => ({ - id: visit.id, - label: encounterKindText(t, visit.kind as EncounterKind), - meta: visit.practitioner?.name ?? null, - occurredAt: visit.occurredAt, - href: visitHref(visit.id), - })), + [...encounters.items] + // Two arms per page arrive in two orders; the list reads newest first. + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)) + .map((visit) => ({ + id: visit.id, + label: encounterKindText(t, visit.kind as EncounterKind), + meta: visit.practitioner?.name ?? null, + occurredAt: visit.occurredAt, + href: visitHref(visit.id), + })), anchor, labels, ); @@ -176,6 +217,19 @@ export function DocumentRecordLinks({ const showDoses = vaccinationsOn && doc.vaccinationLinks !== null; + // The visits the document is filed against, as its own read names them, + // so their chips show whatever page of visits is loaded. + const knownVisits = useMemo( + () => + doc.encounterLinks.map((link) => ({ + id: link.encounterId, + label: encounterKindText(t, link.kind as EncounterKind), + dateLabel: link.occurredAt ? format.date(link.occurredAt) : null, + href: visitHref(link.encounterId), + })), + [doc.encounterLinks, t, format], + ); + return ( <> {canManage ? ( @@ -183,11 +237,11 @@ export function DocumentRecordLinks({ icon={CalendarDays} title={t("documents.detail.visitsLabel")} slot="document-visit-links" - pending={encounters.isPending || !seedFresh} - error={encounters.isError || seedError} + pending={encounters.pending || !seedFresh} + error={encounters.error || seedError} errorLabel={t("links.picker.loadError")} onRetry={() => { - if (encounters.isError) void encounters.refetch(); + if (encounters.error) encounters.retry(); if (seedError) onRetrySeed?.(); }} selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)} @@ -196,6 +250,8 @@ export function DocumentRecordLinks({ onChange({ encounterIds: ids }); }} options={visitOptions} + server={serverPagingFrom(encounters)} + knownOptions={knownVisits} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("documents.detail.noVisitsToLink")} /> diff --git a/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts b/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts deleted file mode 100644 index 9cae124b8..000000000 --- a/src/components/encounters/__tests__/encounter-link-picker-limits.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness"; -import { listLabResultsSchema } from "@/lib/validations/labs"; -import { - EPISODE_FETCH_LIMIT, - PICKER_FETCH_LIMIT, -} from "../encounter-link-pickers"; - -/** - * Each picker's page size must be one its list route accepts. A refused size - * comes back as a 422, and a picker that treats a failed read as an empty - * list tells the person there is nothing to link. That is how the visit - * form's condition picker and both document pickers showed nothing at all. - */ -describe("visit link picker page sizes", () => { - it("the condition picker asks for a page the episode route accepts", () => { - expect( - illnessEpisodeListQuerySchema.safeParse({ - limit: String(EPISODE_FETCH_LIMIT), - includeResolved: "true", - }).success, - ).toBe(true); - }); - - it("the lab picker asks for a page the lab route accepts", () => { - expect( - listLabResultsSchema.safeParse({ limit: String(PICKER_FETCH_LIMIT) }) - .success, - ).toBe(true); - }); -}); diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx index 3b65668d5..9183aa7c9 100644 --- a/src/components/encounters/encounter-form.tsx +++ b/src/components/encounters/encounter-form.tsx @@ -33,6 +33,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters"; import type { Practitioner } from "@/hooks/use-practitioners"; +import type { EncounterLinksDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_KINDS, ENCOUNTER_STATUSES, @@ -57,6 +58,12 @@ export interface EncounterDraft { documentIds: string[]; labResultIds: string[]; episodeIds: string[]; + /** + * The links the visit carried when the sheet opened, as its read names + * them, so their chips show whatever page the pickers have loaded. Display + * only; the id arrays above are what a save writes. + */ + links: EncounterLinksDTO | null; } function localValue(instant: Date): string { @@ -91,6 +98,7 @@ export function emptyDraft( documentIds: [], labResultIds: [], episodeIds: [], + links: null, ...overrides, }; } @@ -108,6 +116,7 @@ export function draftFromEncounter(row: Encounter): EncounterDraft { documentIds: row.links?.documents.map((link) => link.id) ?? [], labResultIds: row.links?.labResults.map((link) => link.id) ?? [], episodeIds: row.links?.conditions.map((link) => link.id) ?? [], + links: row.links ?? null, }; } @@ -303,6 +312,7 @@ export function EncounterForm({ ; + results: LabPickerResult[]; +} + +/** One picker page of lab results. */ +export async function fetchLabPage( + request: PickerPageRequest, +): Promise> { + const page = await apiGet(labPickerUrl(request)); + return { + items: page.results, + next: nextOffset(request.cursor, page.results.length), + }; +} + +/** One picker page of condition episodes. */ +export async function fetchEpisodePage( + request: PickerPageRequest, +): Promise> { + const rows = await apiGet(episodePickerUrl(request)); + return { items: rows, next: nextOffset(request.cursor, rows.length) }; +} + +/** A visit's existing link as a chip option, or null when it is withheld. */ +function linkedOption( + link: { id: string; label: string | null; date: string | null }, + date: (iso: string) => string, + href?: string, +): EntityLinkOption | null { + if (link.label === null) return null; + return { + id: link.id, + label: link.label, + dateLabel: link.date ? date(link.date) : null, + ...(href ? { href } : {}), + }; } export function EncounterLinkPickers({ modules, anchor, + links, documentIds, labResultIds, episodeIds, onChange, }: { modules: Partial> | undefined; - /** The visit's own date, for the document suggestions on top. */ + /** The visit's own date: where every picker opens. */ anchor: string | null; + /** + * What the visit links already, as its own read names it (an edit). Their + * chips show whether or not a loaded page holds them. + */ + links?: EncounterLinksDTO | null; documentIds: string[]; labResultIds: string[]; episodeIds: string[]; @@ -79,64 +135,76 @@ export function EncounterLinkPickers({ const documentsOn = modules?.inboundDocuments === true; const labsOn = modules?.labs === true; const illnessOn = modules?.illness === true; + const pivot = pickerPivot(anchor); - // The whole vault, suggestions near the visit's date on top — see - // `useVaultDocumentOptions` for why this no longer asks for one oversized - // page. + // The vault around the visit's date, suggestions on top — see + // `useVaultDocumentOptions`. const documents = useVaultDocumentOptions({ enabled: documentsOn, anchor, }); - const labs = useQuery({ - queryKey: queryKeys.labResultsPicker(PICKER_FETCH_LIMIT), + const labs = useLinkPickerPages({ enabled: labsOn, - queryFn: () => - apiGet( - `/api/labs?limit=${PICKER_FETCH_LIMIT}&offset=0&sortDir=desc`, - ), + pivot, + queryKey: queryKeys.labResultsPicker, + fetchPage: fetchLabPage, }); - const episodes = useQuery({ - queryKey: queryKeys.illnessEpisodesPicker(EPISODE_FETCH_LIMIT), + const episodes = useLinkPickerPages({ enabled: illnessOn, - queryFn: () => - apiGet( - `/api/illness/episodes?includeResolved=true&limit=${EPISODE_FETCH_LIMIT}`, - ), + pivot, + queryKey: queryKeys.illnessEpisodesPicker, + fetchPage: fetchEpisodePage, }); + const known = useMemo(() => { + const date = (iso: string) => format.date(iso); + const keep = ( + option: EntityLinkOption | null, + ): option is EntityLinkOption => option !== null; + return { + documents: (links?.documents ?? []) + .map((link) => linkedDocumentOption(link, date)) + .filter(keep), + labResults: (links?.labResults ?? []) + .map((link) => linkedOption(link, date)) + .filter(keep), + conditions: (links?.conditions ?? []) + .map((link) => + linkedOption(link, date, `/illness/${encodeURIComponent(link.id)}`), + ) + .filter(keep), + }; + }, [links, format]); + if (!documentsOn && !labsOn && !illnessOn) return null; // Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte // underneath. A visit links the whole panel from a day in one "select all". - const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map( - (result) => { - const dayLabel = format.date(result.takenAt); - const groupLabel = result.panel - ? `${result.panel} · ${dayLabel}` - : dayLabel; - return { - id: result.id, - label: result.analyte, - dateLabel: dayLabel, - group: { - key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`, - label: groupLabel, - }, - }; - }, - ); + const labOptions: EntityLinkOption[] = labs.items.map((result) => { + const dayLabel = format.date(result.takenAt); + const groupLabel = result.panel + ? `${result.panel} · ${dayLabel}` + : dayLabel; + return { + id: result.id, + label: result.analyte, + dateLabel: dayLabel, + group: { + key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`, + label: groupLabel, + }, + }; + }); - // Episodes stay flat (a bounded list), newest onset first from the route. - const episodeOptions: EntityLinkOption[] = (episodes.data ?? []).map( - (episode) => ({ - id: episode.id, - label: episode.label, - dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null, - href: `/illness/${encodeURIComponent(episode.id)}`, - }), - ); + // Episodes stay flat, newest onset first. + const episodeOptions: EntityLinkOption[] = episodes.items.map((episode) => ({ + id: episode.id, + label: episode.label, + dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null, + href: `/illness/${encodeURIComponent(episode.id)}`, + })); return (
@@ -152,6 +220,8 @@ export function EncounterLinkPickers({ selected={documentIds} onChange={(ids) => onChange({ documentIds: ids })} options={documents.options} + server={documents.server} + knownOptions={known.documents} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} extraAction={ @@ -173,13 +243,15 @@ export function EncounterLinkPickers({ icon={FlaskConical} title={t("encounters.form.linkLabResults")} slot="encounter-link-labs" - pending={labs.isPending} - error={labs.isError} + pending={labs.pending} + error={labs.error} errorLabel={t("links.picker.loadError")} - onRetry={() => void labs.refetch()} + onRetry={labs.retry} selected={labResultIds} onChange={(ids) => onChange({ labResultIds: ids })} options={labOptions} + server={serverPagingFrom(labs)} + knownOptions={known.labResults} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} /> @@ -190,13 +262,15 @@ export function EncounterLinkPickers({ icon={Stethoscope} title={t("encounters.form.linkConditions")} slot="encounter-link-conditions" - pending={episodes.isPending} - error={episodes.isError} + pending={episodes.pending} + error={episodes.error} errorLabel={t("links.picker.loadError")} - onRetry={() => void episodes.refetch()} + onRetry={episodes.retry} selected={episodeIds} onChange={(ids) => onChange({ episodeIds: ids })} options={episodeOptions} + server={serverPagingFrom(episodes)} + knownOptions={known.conditions} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("encounters.form.linkNothingToOffer")} /> diff --git a/src/components/links/__tests__/entity-link-picker-paging.test.tsx b/src/components/links/__tests__/entity-link-picker-paging.test.tsx new file mode 100644 index 000000000..8c7e67de3 --- /dev/null +++ b/src/components/links/__tests__/entity-link-picker-paging.test.tsx @@ -0,0 +1,199 @@ +/** + * The link picker over a list that pages on the server. + * + * A visit's link pickers used to offer one capped page and nothing past it. + * Paged, three things have to hold: what the record already links shows + * whether or not a loaded page holds it, the sheet offers the paging the + * server reports, and an empty first window is not read as "nothing to link". + * + * ResponsiveSheet portals its body, which static markup does not render, so it + * is mocked down to a wrapper that always shows its children. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { FlaskConical } from "lucide-react"; + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + EntityLinkPicker, + resolvableOptions, + type EntityLinkOption, + type EntityLinkServerPaging, +} from "../entity-link-picker"; +import { mergeWindowPages, uniqueById } from "../use-link-picker-pages"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const server = (over: Partial = {}) => ({ + term: "", + onTermChange: () => undefined, + searchPending: false, + hasOlder: false, + hasNewer: false, + loadingOlder: false, + loadingNewer: false, + onOlder: () => undefined, + onNewer: () => undefined, + ...over, +}); + +const base = { + icon: FlaskConical, + title: "Lab results", + slot: "labs", + searchPlaceholder: "Search", + emptyLabel: "Nothing to link", + pending: false, + onChange: () => undefined, +}; + +const loaded: EntityLinkOption[] = [ + { id: "recent", label: "Glucose", dateLabel: "12.05.2026" }, +]; +const linked2019: EntityLinkOption = { + id: "ferritin-2019", + label: "Ferritin", + dateLabel: "03.04.2019", +}; + +describe("paged link picker", () => { + it("a linked record outside every loaded page still shows as a chip and in the sheet", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="labs-chip"'); + expect(html).toContain("Ferritin"); + expect(html).toContain("03.04.2019"); + // In the sheet, on top under its own heading. + expect(html).toContain("Linked"); + }); + + it("offers older and newer pages while the server reports them", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="labs-load-older"'); + expect(html).toContain('data-slot="labs-load-newer"'); + expect(html).toContain("Load older"); + expect(html).toContain("Load newer"); + }); + + it("offers no paging once the server has no more", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="labs-load-older"'); + expect(html).not.toContain('data-slot="labs-load-newer"'); + }); + + it("an empty first window with more to page is not 'nothing to link'", () => { + const html = render( + , + ); + expect(html).not.toContain("Nothing to link"); + expect(html).toContain('data-slot="labs-add"'); + }); + + it("does not filter a server answer again by the term", () => { + // The route matched on the panel name; the option does not render it. + const html = render( + , + ); + expect(html).toContain('data-slot="labs-option"'); + }); + + it("a route without search filters what is loaded", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="labs-option"'); + }); +}); + +describe("resolvableOptions", () => { + it("prefers the loaded option, then the known one, then a remembered pick", () => { + const picked = new Map([ + ["a", { id: "a", label: "picked" }], + ["b", { id: "b", label: "picked" }], + ["c", { id: "c", label: "picked" }], + ]); + const map = resolvableOptions( + [{ id: "a", label: "loaded" }], + [ + { id: "a", label: "known" }, + { id: "b", label: "known" }, + ], + picked, + ); + expect(map.get("a")?.label).toBe("loaded"); + expect(map.get("b")?.label).toBe("known"); + expect(map.get("c")?.label).toBe("picked"); + }); +}); + +describe("mergeWindowPages", () => { + it("puts the newer window, newest first, above the older one", () => { + const merged = mergeWindowPages( + [ + { items: [{ id: "n1" }, { id: "n2" }], next: "2" }, + { items: [{ id: "n3" }], next: null }, + ], + [{ items: [{ id: "o1" }, { id: "o2" }], next: null }], + ); + expect(merged.map((item) => item.id)).toEqual([ + "n3", + "n2", + "n1", + "o1", + "o2", + ]); + }); + + it("drops a record the two windows both returned", () => { + expect(uniqueById([{ id: "x" }, { id: "y" }, { id: "x" }])).toEqual([ + { id: "x" }, + { id: "y" }, + ]); + }); +}); diff --git a/src/components/links/__tests__/picker-requests.test.ts b/src/components/links/__tests__/picker-requests.test.ts new file mode 100644 index 000000000..125939e4b --- /dev/null +++ b/src/components/links/__tests__/picker-requests.test.ts @@ -0,0 +1,122 @@ +/** + * Every request a link picker can send must be one its list route accepts. + * + * A refused parameter comes back as a 422, and a picker that reads a failed + * read as an empty list tells the person there is nothing to link. That is how + * the visit form's condition picker and both document pickers once showed + * nothing at all. Each builder is checked in every direction, with and without + * a date, against the very schema its route parses with. + */ +import { describe, expect, it } from "vitest"; + +import { documentListQuerySchema } from "@/lib/validations/inbound-documents"; +import { encounterListQuerySchema } from "@/lib/validations/encounters"; +import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness"; +import { listLabResultsSchema } from "@/lib/validations/labs"; + +import { + documentPickerUrl, + encounterPickerUrl, + episodePickerUrl, + labPickerUrl, +} from "../picker-requests"; +import { + PICKER_PAGE_SIZE, + nextOffset, + pickerPivot, + type PickerPageRequest, +} from "../use-link-picker-pages"; + +const pivot = pickerPivot("2019-05-10T10:00:00.000Z"); + +const REQUESTS: PickerPageRequest[] = [ + { direction: "older", pivot, term: "", cursor: null }, + { direction: "older", pivot, term: "", cursor: "200" }, + { direction: "older", pivot: null, term: "", cursor: null }, + { direction: "newer", pivot, term: "", cursor: null }, + { direction: "newer", pivot, term: "", cursor: "100" }, + { direction: "search", pivot: null, term: "ferritin", cursor: null }, + { direction: "search", pivot: null, term: "ferritin", cursor: "100" }, +]; + +function params(url: string): Record { + return Object.fromEntries(new URL(url, "http://local").searchParams); +} + +describe.each([ + ["labs", labPickerUrl, listLabResultsSchema], + ["episodes", episodePickerUrl, illnessEpisodeListQuerySchema], + ["encounters", encounterPickerUrl, encounterListQuerySchema], +])("%s picker", (_name, build, schema) => { + it.each(REQUESTS)("$direction (cursor $cursor) parses", (request) => { + const result = schema.safeParse(params(build(request))); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); +}); + +describe("documents picker", () => { + it.each(REQUESTS)("$direction parses", (request) => { + // The document cursor is a row id, not an offset. + const url = documentPickerUrl({ + ...request, + cursor: request.cursor ? "doc-id" : null, + }); + const result = documentListQuerySchema.safeParse(params(url)); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it("splits the day-granular window at the pivot's day", () => { + const older = params( + documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + const newer = params( + documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + ); + expect(older.to).toBe("2019-05-17"); + expect(newer.from).toBe("2019-05-18"); + expect(older.order).toBe("desc"); + expect(newer.order).toBe("asc"); + }); +}); + +describe("the windows around a record's date", () => { + it("splits a week after the date, at the end of that day", () => { + expect(pivot).toBe("2019-05-17T23:59:59.999Z"); + expect(pickerPivot(null)).toBeNull(); + expect(pickerPivot("not a date")).toBeNull(); + }); + + it("older reads up to the split newest first, newer after it oldest first", () => { + const older = params( + labPickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + const newer = params( + labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }), + ); + expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" }); + expect(newer).toMatchObject({ + from: "2019-05-18T00:00:00.000Z", + sortDir: "asc", + }); + }); + + it("a search ignores the window and sends the term", () => { + const search = params( + labPickerUrl({ + direction: "search", + pivot: null, + term: "ferritin", + cursor: null, + }), + ); + expect(search.q).toBe("ferritin"); + expect(search.from).toBeUndefined(); + expect(search.to).toBeUndefined(); + }); + + it("an offset page that came back full has a next page, a short one has none", () => { + expect(nextOffset(null, PICKER_PAGE_SIZE)).toBe(String(PICKER_PAGE_SIZE)); + expect(nextOffset("100", PICKER_PAGE_SIZE)).toBe("200"); + expect(nextOffset("100", 3)).toBeNull(); + }); +}); diff --git a/src/components/links/__tests__/vault-document-options.test.ts b/src/components/links/__tests__/vault-document-options.test.ts index e0dbbace6..049c915d8 100644 --- a/src/components/links/__tests__/vault-document-options.test.ts +++ b/src/components/links/__tests__/vault-document-options.test.ts @@ -91,37 +91,36 @@ describe("documentOptionsWithSuggestions", () => { }); }); -describe("fetchWholeVault", () => { - it("asks only for pages the list route accepts, and walks every cursor", async () => { +describe("fetchVaultPage", () => { + it("asks for one page the list route accepts and hands back its cursor", async () => { const { documentListQuerySchema } = await import("@/lib/validations/inbound-documents"); const apiFetch = await import("@/lib/api/api-fetch"); const urls: string[] = []; - const pages = [ - { documents: [doc("a", "2026-05-01")], nextCursor: "a" }, - { documents: [doc("b", "1991-03-01")], nextCursor: null }, - ]; const spy = vi .spyOn(apiFetch, "apiGet") .mockImplementation(async (url: string) => { urls.push(url); - return pages.shift() as never; + return { + documents: [doc("a", "2026-05-01")], + nextCursor: "a", + } as never; }); try { - const { fetchWholeVault } = await import("../vault-document-options"); - const documents = await fetchWholeVault(); - expect(documents.map((d) => d.id)).toEqual(["a", "b"]); - expect(urls).toHaveLength(2); - for (const url of urls) { - // The route parses its query with this schema and answers a 422 to - // anything it refuses; the picker used to read that 422 as an empty - // vault. - const params = Object.fromEntries( - new URL(url, "http://local").searchParams, - ); - expect(documentListQuerySchema.safeParse(params).success).toBe(true); - } - expect(urls[1]).toContain("cursor=a"); + const { fetchVaultPage } = await import("../vault-document-options"); + const page = await fetchVaultPage({ + direction: "older", + pivot: "2026-05-17T23:59:59.999Z", + term: "", + cursor: "z", + }); + expect(page.items.map((d) => d.id)).toEqual(["a"]); + expect(page.next).toBe("a"); + const params = Object.fromEntries( + new URL(urls[0]!, "http://local").searchParams, + ); + expect(documentListQuerySchema.safeParse(params).success).toBe(true); + expect(params).toMatchObject({ cursor: "z", to: "2026-05-17" }); } finally { spy.mockRestore(); } diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index 8d70d10b0..919dfd9e7 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -30,10 +30,18 @@ * sheet where its button cannot be pressed while the sheet is open. The chip * carries the option's date, so two doses of one vaccine read as two * different records. + * + * **A long list pages on the server.** A caller over a list that can outgrow + * one page passes `server`: the search box then asks the list route instead + * of filtering what is loaded, and "Load newer" / "Load older" page through + * the windows around the record's date (see `useLinkPickerPages`). A record + * that is linked always shows as a chip, whether or not the page that holds + * it is loaded: the caller passes what it knows about the existing links as + * `knownOptions`, and every option picked in this session is remembered. */ import { useMemo, useState, type ReactNode } from "react"; import Link from "next/link"; -import { Check, Plus, Search, X } from "lucide-react"; +import { Check, Loader2, Plus, Search, X } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -82,6 +90,74 @@ export function filterOptions( return options.filter((option) => haystack(option).includes(needle)); } +/** + * Server-side search and paging for a list that can outgrow one page. The + * caller owns the term (it is part of the list route's query) and the pages. + */ +export interface EntityLinkServerPaging { + term: string; + onTermChange: (term: string) => void; + /** The typed term has not been answered yet. */ + searchPending: boolean; + hasOlder: boolean; + hasNewer: boolean; + loadingOlder: boolean; + loadingNewer: boolean; + onOlder: () => void; + onNewer: () => void; + /** + * The list route has no text search: the term filters the loaded options + * here instead of being sent. + */ + filterLocally?: boolean; +} + +/** + * The paging props from a `useLinkPickerPages` result, so every caller + * wires the same fields the same way. + */ +export function serverPagingFrom(pages: { + term: string; + setTerm: (term: string) => void; + searchPending: boolean; + hasOlder: boolean; + hasNewer: boolean; + loadingOlder: boolean; + loadingNewer: boolean; + loadOlder: () => void; + loadNewer: () => void; + filterLocally: boolean; +}): EntityLinkServerPaging { + return { + term: pages.term, + onTermChange: pages.setTerm, + searchPending: pages.searchPending, + hasOlder: pages.hasOlder, + hasNewer: pages.hasNewer, + loadingOlder: pages.loadingOlder, + loadingNewer: pages.loadingNewer, + onOlder: pages.loadOlder, + onNewer: pages.loadNewer, + filterLocally: pages.filterLocally, + }; +} + +/** + * The options a chip can be resolved from: what is loaded, what the caller + * knows about the existing links, and what was picked in this session. The + * loaded option wins, then the known one, then the picked one. + */ +export function resolvableOptions( + options: readonly EntityLinkOption[], + knownOptions: readonly EntityLinkOption[], + picked: ReadonlyMap, +): Map { + const map = new Map(picked); + for (const option of knownOptions) map.set(option.id, option); + for (const option of options) map.set(option.id, option); + return map; +} + export interface EntityLinkGroup { /** Group key, or null for the flat (ungrouped) bucket. */ key: string | null; @@ -165,6 +241,8 @@ export function EntityLinkPicker({ errorLabel, onRetry, extraAction, + server, + knownOptions, }: { icon: LucideIcon; title: string; @@ -191,27 +269,68 @@ export function EntityLinkPicker({ * the way forward. The element renders nothing when it has nothing to offer. */ extraAction?: ReactNode; + /** Server-side search and paging; omit for a list that fits one read. */ + server?: EntityLinkServerPaging; + /** + * What the caller knows about records already linked (label and date from + * the record's own read), so their chips show even when no loaded page + * holds them. + */ + knownOptions?: EntityLinkOption[]; }) { const { t } = useTranslations(); const [open, setOpen] = useState(false); - const [term, setTerm] = useState(""); + const [localTerm, setLocalTerm] = useState(""); + const term = server ? server.term : localTerm; + const setTerm = server ? server.onTermChange : setLocalTerm; - const optionById = useMemo(() => { - const map = new Map(); - for (const option of options) map.set(option.id, option); - return map; - }, [options]); - - const groups = useMemo( - () => groupOptions(filterOptions(options, term)), - [options, term], + // Every option picked in this session, so its chip survives a search or a + // page change that takes it out of the loaded list. Written from the event + // handlers, never during render. + const [picked, setPicked] = useState>( + () => new Map(), ); + const remember = (more: readonly EntityLinkOption[]) => { + if (more.length === 0) return; + setPicked((prev) => { + const next = new Map(prev); + for (const option of more) next.set(option.id, option); + return next; + }); + }; + + const optionById = useMemo( + () => resolvableOptions(options, knownOptions ?? [], picked), + [options, knownOptions, picked], + ); + + // A server-paged list is already the answer to the term; filtering it + // again here would drop a match on a field the option does not render. + const groups = useMemo(() => { + const visible = + server && !server.filterLocally ? options : filterOptions(options, term); + // Linked records the loaded pages do not hold stay reachable in the + // sheet, on top, so they can be unticked where they are listed. + const loaded = new Set(options.map((option) => option.id)); + const linkedElsewhere = term.trim() + ? [] + : selected + .filter((id) => !loaded.has(id)) + .map((id) => optionById.get(id)) + .filter((option): option is EntityLinkOption => option != null) + .map((option) => ({ + ...option, + group: { key: "__linked__", label: t("links.picker.linked") }, + })); + return groupOptions([...linkedElsewhere, ...visible]); + }, [options, term, server, selected, optionById, t]); // The link the last X removed, offered back until the next change. const [lastRemoved, setLastRemoved] = useState(null); - const change = (ids: string[]) => { + const change = (ids: string[], touched: readonly EntityLinkOption[] = []) => { setLastRemoved(null); + remember(touched); onChange(ids); }; @@ -245,7 +364,9 @@ export function EntityLinkPicker({ onRetry={onRetry} slot={`${slot}-error`} /> - ) : options.length === 0 ? ( + ) : options.length === 0 && + selected.length === 0 && + !(server && (server.hasOlder || server.hasNewer)) ? (

{emptyLabel}

{extraAction} @@ -315,7 +436,9 @@ export function EntityLinkPicker({
- {groups.length === 0 ? ( + {server?.searchPending && groups.length === 0 ? ( +
+ +
+ ) : groups.length === 0 && + !(server && (server.hasOlder || server.hasNewer)) ? (

{t("links.picker.searchNoMatch")}

) : (
+ {server?.hasNewer ? ( + + ) : null} {groups.map((group) => { const groupIds = group.options.map((option) => option.id); return ( @@ -384,7 +526,9 @@ export function EntityLinkPicker({
); })} + {server?.hasOlder ? ( + + ) : null}
)} @@ -451,3 +603,37 @@ export function EntityLinkPicker({ ); } + +/** "Load newer" / "Load older": a full-width row inside the scrolling list. */ +function PageButton({ + slot, + loading, + onClick, + label, +}: { + slot: string; + loading: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} diff --git a/src/components/links/picker-requests.ts b/src/components/links/picker-requests.ts new file mode 100644 index 000000000..2d4be8381 --- /dev/null +++ b/src/components/links/picker-requests.ts @@ -0,0 +1,96 @@ +/** + * The list-route requests behind each link picker, one builder per family. + * + * Pure, so a test can parse every URL a picker can send against the route's + * own query schema: a page size or a parameter the route refuses answers 422, + * and a picker that reads a 422 as an empty list hides the record it exists to + * offer. That is exactly how a vault once read as empty. + * + * Every builder speaks one {@link PickerPageRequest}: `older` is the window up + * to the split newest first, `newer` the window after it oldest first, + * `search` the term across the whole history. The cursor is the family's own: + * an offset for the routes that page by offset, the last row's id for the + * document route's keyset. + */ +import { + PICKER_PAGE_SIZE, + afterPivot, + type PickerPageRequest, +} from "./use-link-picker-pages"; + +function withParams(path: string, params: URLSearchParams): string { + return `${path}?${params.toString()}`; +} + +/** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */ +export function labPickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + limit: String(PICKER_PAGE_SIZE), + offset: request.cursor ?? "0", + sortDir: request.direction === "newer" ? "asc" : "desc", + }); + if (request.direction === "search") params.set("q", request.term); + else if (request.pivot !== null) { + if (request.direction === "older") params.set("to", request.pivot); + else params.set("from", afterPivot(request.pivot)); + } + return withParams("/api/labs", params); +} + +/** `GET /api/illness/episodes` — offset paging, the window on the onset. */ +export function episodePickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + includeResolved: "true", + limit: String(PICKER_PAGE_SIZE), + offset: request.cursor ?? "0", + sortDir: request.direction === "newer" ? "asc" : "desc", + }); + if (request.direction === "search") params.set("q", request.term); + else if (request.pivot !== null) { + if (request.direction === "older") params.set("to", request.pivot); + else params.set("from", afterPivot(request.pivot)); + } + return withParams("/api/illness/episodes", params); +} + +/** + * `GET /api/documents/inbound` — keyset paging on the filing date. The route's + * window is day-granular and inclusive, so the newer window starts the day + * after the split's day. + */ +export function documentPickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + sort: "documentDate", + order: request.direction === "newer" ? "asc" : "desc", + limit: String(PICKER_PAGE_SIZE), + }); + if (request.cursor) params.set("cursor", request.cursor); + if (request.direction === "search") params.set("q", request.term); + else if (request.pivot !== null) { + if (request.direction === "older") { + params.set("to", request.pivot.slice(0, 10)); + } else { + params.set("from", afterPivot(request.pivot).slice(0, 10)); + } + } + return withParams("/api/documents/inbound", params); +} + +/** + * `GET /api/encounters` — offset paging inside a window on the visit instant. + * The route answers in two arms (upcoming soonest first, past newest first), + * each paged by the same offset; the picker merges and orders them itself. + * The visit route has no text search, so `search` reads the whole history + * and the picker filters what it has loaded. + */ +export function encounterPickerUrl(request: PickerPageRequest): string { + const params = new URLSearchParams({ + limit: String(PICKER_PAGE_SIZE), + offset: request.cursor ?? "0", + }); + if (request.direction !== "search" && request.pivot !== null) { + if (request.direction === "older") params.set("to", request.pivot); + else params.set("from", afterPivot(request.pivot)); + } + return withParams("/api/encounters", params); +} diff --git a/src/components/links/use-link-picker-pages.ts b/src/components/links/use-link-picker-pages.ts new file mode 100644 index 000000000..28f9e1f41 --- /dev/null +++ b/src/components/links/use-link-picker-pages.ts @@ -0,0 +1,235 @@ +"use client"; + +/** + * The data behind a link picker over a list that can be long: the records + * around one date, paged older and newer from there, or a search across the + * whole history. + * + * The pickers used to fetch one capped page, newest first (200 lab results, + * 100 conditions, ten pages of documents, the last 100 visits), and offered + * only that. On a long record everything older than the cap could not be + * linked at all: a visit from 2019 could not reach its own 2019 lab panel. + * + * Now a picker opens on the records around the date of the thing being linked + * (the visit's, the dose's, the document's). The list is split a few days + * after that date (the same window the "belongs to a visit?" suggestion uses, + * so a result drawn two days after the visit is on the first page) into two + * server-side windows: + * + * - `older`: up to the split, newest first, so the record's own days lead + * the list, paged further back on request; + * - `newer`: after the split, oldest first, fetched only when asked for and + * paged further forward from there; + * + * and a typed term switches to `search`, which asks the list route itself + * (`q`) so a match is found wherever it sits in the history. With no date + * yet the picker opens on the newest records and pages back. + * + * Every page comes from the family's own list route through `fetchPage`, so + * paging, the filter and the ownership check stay server-side. + */ +import { useMemo, useState } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; + +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window"; + +export type PickerDirection = "older" | "newer" | "search"; + +/** One page of a picker list, and the cursor for the next one (or null). */ +export interface PickerPage { + items: T[]; + next: string | null; +} + +export interface PickerPageRequest { + direction: PickerDirection; + /** The split instant (ISO), or null when the record has no date yet. */ + pivot: string | null; + /** The trimmed search term; empty outside `search`. */ + term: string; + /** The family's own cursor (an offset or a row id), null for page one. */ + cursor: string | null; +} + +/** Records per page, the same for every family. */ +export const PICKER_PAGE_SIZE = 100; + +/** How long typing settles before the search asks the server. */ +const SEARCH_DEBOUNCE_MS = 250; + +/** Drop repeats by id, keeping the first occurrence's position. */ +export function uniqueById(items: readonly T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const item of items) { + if (seen.has(item.id)) continue; + seen.add(item.id); + out.push(item); + } + return out; +} + +/** + * The visible list, newest first: the newer pages (fetched oldest first, so + * reversed) above the older ones. + */ +export function mergeWindowPages( + newerPages: ReadonlyArray>, + olderPages: ReadonlyArray>, +): T[] { + const newer = newerPages.flatMap((page) => page.items).reverse(); + const older = olderPages.flatMap((page) => page.items); + return uniqueById([...newer, ...older]); +} + +export function useLinkPickerPages({ + enabled, + pivot, + queryKey, + fetchPage, + serverSearch = true, +}: { + enabled: boolean; + pivot: string | null; + /** The family's factory key for one direction, pivot and term. */ + queryKey: ( + direction: PickerDirection, + pivot: string | null, + term: string, + ) => readonly unknown[]; + fetchPage: (request: PickerPageRequest) => Promise>; + /** + * Whether the list route can search. A route without a text filter (the + * visit list: its free text is ciphertext at rest) leaves the term to the + * picker, which filters what is loaded. + */ + serverSearch?: boolean; +}) { + const [rawTerm, setTerm] = useState(""); + const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS); + const term = serverSearch ? debounced : ""; + const searching = term.length > 0; + + const older = useInfiniteQuery({ + queryKey: queryKey("older", pivot, ""), + enabled: enabled && !searching, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }), + getNextPageParam: (last) => last.next ?? undefined, + }); + + // The newer window is read only once asked for, and only for the split it + // was asked for: a changed date starts over on the older window. + const [newerFor, setNewerFor] = useState(null); + const newerRequested = pivot !== null && newerFor === pivot; + + const newer = useInfiniteQuery({ + queryKey: queryKey("newer", pivot, ""), + // No date, no "after": the older window already starts at the newest. + enabled: enabled && !searching && newerRequested, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }), + getNextPageParam: (last) => last.next ?? undefined, + }); + + const search = useInfiniteQuery({ + queryKey: queryKey("search", null, term), + enabled: enabled && searching, + initialPageParam: null as string | null, + queryFn: ({ pageParam }) => + fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }), + getNextPageParam: (last) => last.next ?? undefined, + }); + + const items = useMemo(() => { + if (searching) { + return uniqueById((search.data?.pages ?? []).flatMap((p) => p.items)); + } + return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []); + }, [searching, search.data, newer.data, older.data]); + + const activeError = searching + ? search.isError + : older.isError || (newerRequested && newer.isError); + + return { + items, + /** The first window is still loading; the inline block shows a skeleton. */ + pending: older.isPending && !searching, + error: activeError, + retry: () => { + if (searching) void search.refetch(); + else { + void older.refetch(); + if (newerRequested) void newer.refetch(); + } + }, + term: rawTerm, + setTerm, + /** A search is in flight (typed, or its first page not back yet). */ + searchPending: + serverSearch && + (rawTerm.trim() !== term || (searching && search.isPending)), + /** The picker filters the loaded options by the term itself. */ + filterLocally: !serverSearch, + hasOlder: searching ? search.hasNextPage : older.hasNextPage, + // Unknown until asked: offered once for every dated record, then only + // while the server reports another page. + hasNewer: + !searching && + pivot !== null && + (!newerRequested || newer.isFetching || newer.hasNextPage), + loadingOlder: searching + ? search.isFetchingNextPage + : older.isFetchingNextPage, + loadingNewer: !searching && newerRequested && newer.isFetching, + loadOlder: () => { + if (searching) void search.fetchNextPage(); + else void older.fetchNextPage(); + }, + loadNewer: () => { + if (searching || pivot === null) return; + if (!newerRequested) setNewerFor(pivot); + else void newer.fetchNextPage(); + }, + }; +} + +export type LinkPickerPages = ReturnType< + typeof useLinkPickerPages +>; + +/** + * Where a record's date splits the list: the end of the UTC day + * {@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days after it, so everything the + * suggestion window would offer is on the first page. Day-granular so typing + * the time of a visit does not refetch. Null when there is no date (or it does + * not parse). + */ +export function pickerPivot(anchor: string | null): string | null { + if (!anchor) return null; + const parsed = new Date(anchor); + if (Number.isNaN(parsed.getTime())) return null; + const split = new Date( + parsed.getTime() + ENCOUNTER_SUGGEST_WINDOW_DAYS * 86_400_000, + ); + split.setUTCHours(23, 59, 59, 999); + return split.toISOString(); +} + +/** The instant just after the split, where the newer window starts. */ +export function afterPivot(pivot: string): string { + return new Date(new Date(pivot).getTime() + 1).toISOString(); +} + +/** Offset cursors, for the list routes that page by `offset`. */ +export function nextOffset( + cursor: string | null, + received: number, +): string | null { + if (received < PICKER_PAGE_SIZE) return null; + return String(Number(cursor ?? "0") + received); +} diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts index 91aee8604..3ac090efd 100644 --- a/src/components/links/vault-document-options.ts +++ b/src/components/links/vault-document-options.ts @@ -7,38 +7,41 @@ * The visit and vaccination forms used to fetch their document options with a * page size the list route refuses (`limit=200` against a ceiling of 100). The * 422 read as an empty list, so the picker said "No documents yet." to a - * person with a full vault, and the only way a scan ever got attached was the - * ±7-day suggestion offered at upload. A childhood record covering eight doses - * from one sheet of paper could therefore be filed against exactly one of - * them. + * person with a full vault. The next version walked the whole vault, ten + * pages deep, and anything past the thousandth document was out of reach. * - * Now the whole vault is offered, paged through at the route's own ceiling, - * and a read failure says so instead of pretending the vault is empty. The - * documents dated within the same ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day - * window the upload suggestion uses stay on top under their own heading, so - * the common case is still one tap; everything else follows, grouped by month - * and searchable. + * Now the picker opens on the documents filed around the record's date and + * pages older and newer from there, and the search box asks the list route + * itself, so a document is reachable wherever it sits in a vault of any size + * (`useLinkPickerPages`). A read failure says so instead of pretending the + * vault is empty. The documents dated within the same + * ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day window the upload suggestion uses + * stay on top under their own heading, so the common case is still one tap; + * everything else follows, grouped by month. + * + * A document whose filing date was cleared falls outside every date window; + * the search still finds it by title, file name or content. */ import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import type { EntityLinkOption } from "@/components/links/entity-link-picker"; +import { + serverPagingFrom, + type EntityLinkOption, +} from "@/components/links/entity-link-picker"; +import { documentPickerUrl } from "@/components/links/picker-requests"; +import { + pickerPivot, + useLinkPickerPages, + type PickerPage, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; import { apiGet } from "@/lib/api/api-fetch"; import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { - DOCUMENT_LIST_MAX_LIMIT, - type InboundDocumentDto, -} from "@/lib/validations/inbound-documents"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { daysBetweenDateKeys } from "@/lib/tz/format"; -/** - * How many pages the picker walks. At the route's ceiling of 100 that is a - * thousand documents, past which the vault's own search is the better tool. - */ -const VAULT_PICKER_MAX_PAGES = 10; - /** The fields the option list reads off a document. */ export type VaultDocument = Pick< InboundDocumentDto, @@ -50,25 +53,12 @@ interface ListPage { nextCursor: string | null; } -/** Walk the vault newest filing date first, at the route's own page ceiling. */ -export async function fetchWholeVault(): Promise { - const documents: VaultDocument[] = []; - let cursor: string | null = null; - for (let page = 0; page < VAULT_PICKER_MAX_PAGES; page++) { - const params = new URLSearchParams({ - sort: "documentDate", - order: "desc", - limit: String(DOCUMENT_LIST_MAX_LIMIT), - }); - if (cursor) params.set("cursor", cursor); - const result: ListPage = await apiGet( - `/api/documents/inbound?${params.toString()}`, - ); - documents.push(...result.documents); - cursor = result.nextCursor; - if (!cursor) break; - } - return documents; +/** One picker page of the vault. */ +export async function fetchVaultPage( + request: PickerPageRequest, +): Promise> { + const result = await apiGet(documentPickerUrl(request)); + return { items: result.documents, next: result.nextCursor }; } /** @@ -135,8 +125,9 @@ export function documentOptionsWithSuggestions( } /** - * The picker's data: the whole vault as options, the suggestion group on top - * when the record has a date, and the query state an error row needs. + * The picker's data: the vault around the record's date as options, the + * suggestion group on top when the record has a date, the paging the sheet + * offers, and the query state an error row needs. */ export function useVaultDocumentOptions({ enabled, @@ -148,26 +139,42 @@ export function useVaultDocumentOptions({ }) { const { t } = useTranslations(); const format = useFormatters(); - const query = useQuery({ - queryKey: queryKeys.inboundDocumentVaultPicker(), + const pages = useLinkPickerPages({ enabled, - queryFn: fetchWholeVault, + pivot: pickerPivot(anchor), + queryKey: queryKeys.inboundDocumentVaultPicker, + fetchPage: fetchVaultPage, }); const options = useMemo( () => - documentOptionsWithSuggestions(query.data ?? [], anchor, { + documentOptionsWithSuggestions(pages.items, anchor, { suggested: t("links.picker.suggested"), month: (iso) => `${format.monthShort(iso)} ${iso.slice(0, 4)}`, date: (iso) => format.date(iso), }), - [query.data, anchor, t, format], + [pages.items, anchor, t, format], ); return { options, - pending: query.isPending, - error: query.isError, - retry: () => void query.refetch(), + pending: pages.pending, + error: pages.error, + retry: pages.retry, + server: serverPagingFrom(pages), + }; +} + +/** A linked document as the record's own read names it, for its chip. */ +export function linkedDocumentOption( + link: { id: string; label: string | null; date: string | null }, + date: (iso: string) => string, +): EntityLinkOption | null { + if (link.label === null) return null; + return { + id: link.id, + label: link.label, + dateLabel: link.date ? date(link.date) : null, + href: `/documents?doc=${encodeURIComponent(link.id)}`, }; } diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx index 6fb195261..da8f17a0e 100644 --- a/src/components/vaccinations/vaccination-document-picker.tsx +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -5,10 +5,11 @@ * transcribed from. The shared {@link EntityLinkPicker} — an inline summary * (removable chips + an add button) over a searchable, month-grouped sheet. * - * The whole vault is offered, not only the pages dated near the dose: one + * The whole vault is reachable, not only the pages dated near the dose: one * childhood record commonly covers a dozen doses given years apart, and it - * belongs on every one of them. The pages dated within the upload - * suggestion's window of this dose sit on top as suggestions. + * belongs on every one of them. The picker opens on the pages around the + * dose's date, the ones within the upload suggestion's window on top as + * suggestions, and pages older and newer or searches the vault from there. * * **The gate blanks the block, it does not post-filter it.** When the * `inboundDocuments` module is off, this renders nothing — no heading, no empty @@ -23,11 +24,15 @@ * than showing an empty set, which would both hide the links and offer to * save their removal. */ +import { useMemo } from "react"; import { FolderOpen } from "lucide-react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { EntityLinkPicker } from "@/components/links/entity-link-picker"; -import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; +import { + linkedDocumentOption, + useVaultDocumentOptions, +} from "@/components/links/vault-document-options"; import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button"; import { mergeIds } from "@/components/documents/sources/merge-ids"; @@ -35,6 +40,7 @@ export function VaccinationDocumentPicker({ enabled, anchor, documentIds, + linkedDocuments, seedPending = false, seedError = false, onRetrySeed, @@ -45,6 +51,15 @@ export function VaccinationDocumentPicker({ /** The dose's own date (ISO), for the suggestions on top. */ anchor: string | null; documentIds: string[]; + /** + * The pages the dose is filed against, as its own read names them, so their + * chips show whatever part of the vault is loaded. + */ + linkedDocuments?: ReadonlyArray<{ + id: string; + label: string | null; + date: string | null; + }>; /** The dose's own links are still loading. */ seedPending?: boolean; /** Reading the dose's own links failed. */ @@ -53,7 +68,15 @@ export function VaccinationDocumentPicker({ onChange: (documentIds: string[]) => void; }) { const { t } = useTranslations(); + const format = useFormatters(); const vault = useVaultDocumentOptions({ enabled, anchor }); + const known = useMemo( + () => + (linkedDocuments ?? []) + .map((link) => linkedDocumentOption(link, (iso) => format.date(iso))) + .filter((option) => option !== null), + [linkedDocuments, format], + ); if (!enabled) return null; @@ -73,6 +96,8 @@ export function VaccinationDocumentPicker({ selected={documentIds} onChange={onChange} options={vault.options} + server={vault.server} + knownOptions={known} searchPlaceholder={t("links.picker.searchPlaceholder")} emptyLabel={t("vaccinations.form.linkNothingToOffer")} extraAction={ diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index aff06ca44..bdb30c955 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -191,6 +191,12 @@ export function VaccinationForm({ */ savedDocuments?: { ids: string[] | null; + /** The same links with the label and date the detail read carries. */ + links?: ReadonlyArray<{ + id: string; + label: string | null; + date: string | null; + }>; pending: boolean; error: boolean; retry: () => void; @@ -376,6 +382,7 @@ export function VaccinationForm({ enabled={user?.modules?.inboundDocuments === true} anchor={anchor} documentIds={draft.documentIds ?? savedDocuments?.ids ?? []} + linkedDocuments={savedDocuments?.links} seedPending={ draft.documentIds === null && (savedDocuments?.pending ?? false) } diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 3f76cc21b..3cb0e8130 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -141,6 +141,7 @@ export function VaccinationSheet({ ids: detail.data?.documents?.map((document) => document.id) ?? null, + links: detail.data?.documents, pending: detail.isPending, error: detail.isError, retry: () => void detail.refetch(), diff --git a/src/lib/query-keys/__tests__/picker-list-keys.test.ts b/src/lib/query-keys/__tests__/picker-list-keys.test.ts index ec41804a9..f22bfd8be 100644 --- a/src/lib/query-keys/__tests__/picker-list-keys.test.ts +++ b/src/lib/query-keys/__tests__/picker-list-keys.test.ts @@ -1,11 +1,11 @@ /** * One key, one fetch. The visit form's link pickers read labs and illness - * episodes with their own page size (200 results, 100 episodes), the Labs - * list and the illness hook with theirs (500, the route's default 50). Under - * a shared key whichever read landed first served the other, so the picker - * could offer 50 episodes or the Labs list could render a 200-row page as - * the whole list. The picker reads carry their own keys, still under the - * domain roots so a lab or episode write evicts them. + * episodes in their own windows and pages, the Labs list and the illness hook + * with theirs. Under a shared key whichever read landed first served the + * other, so the picker could offer 50 episodes or the Labs list could render + * a picker page as the whole list. The picker reads carry their own keys, + * still under the domain roots so a lab or episode write evicts them, and + * each direction, split and term is its own key. */ import { describe, expect, it } from "vitest"; @@ -22,20 +22,36 @@ const LIST_PARAMS = { describe("the link pickers' reads have keys of their own", () => { it("labs: differs from the Labs list and sits under the lab root", () => { - const key = queryKeys.labResultsPicker(200); + const key = queryKeys.labResultsPicker("older", null, ""); expect(key).not.toEqual(queryKeys.labResultsList(LIST_PARAMS)); expect(key.slice(0, 1)).toEqual(queryKeys.labResults()); }); it("episodes: differs from the illness list and sits under the illness root", () => { - const key = queryKeys.illnessEpisodesPicker(100); + const key = queryKeys.illnessEpisodesPicker("older", null, ""); expect(key).not.toEqual(queryKeys.illnessEpisodes(true)); expect(key.slice(0, 1)).toEqual(queryKeys.illness()); }); - it("a different page size is a different key", () => { - expect(queryKeys.labResultsPicker(200)).not.toEqual( - queryKeys.labResultsPicker(500), + it("a different direction, split or term is a different key", () => { + const base = queryKeys.labResultsPicker("older", "2019-05-17", ""); + expect(base).not.toEqual( + queryKeys.labResultsPicker("newer", "2019-05-17", ""), + ); + expect(base).not.toEqual( + queryKeys.labResultsPicker("older", "2020-01-01", ""), + ); + expect(queryKeys.labResultsPicker("search", null, "a")).not.toEqual( + queryKeys.labResultsPicker("search", null, "b"), + ); + }); + + it("documents and visits: under their roots", () => { + expect( + queryKeys.inboundDocumentVaultPicker("older", null, "").slice(0, 1), + ).toEqual(["documents"]); + expect(queryKeys.encounterPicker("older", null, "").slice(0, 1)).toEqual( + queryKeys.encounters(), ); }); }); diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index a562672b7..beee7a450 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -61,12 +61,24 @@ export const documentKeys = { inboundDocumentPicker: (q: string) => ["documents", "inbound", "picker", q] as const, /** - * The whole vault as a link-picker option list (every page, walked at the - * list route's ceiling), shared by the visit and vaccination forms. Own - * segment because the shape is a flat document array, not a page. + * The vault as a link-picker option list, shared by the visit and + * vaccination forms: one paged direction from the record's date or a + * search. Own segment because the page shape is the picker's, not the + * library's. */ - inboundDocumentVaultPicker: () => - ["documents", "inbound", "vault-picker"] as const, + inboundDocumentVaultPicker: ( + direction: string, + pivot: string | null, + term: string, + ) => + [ + "documents", + "inbound", + "vault-picker", + direction, + pivot ?? "", + term, + ] as const, /** * Storage usage + effective limits (`GET /api/documents/inbound/usage`). * Invalidated through the `["documents"]` prefix after every upload / diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index f9c5586ad..84fab90e2 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -45,6 +45,13 @@ export const encounterKeys = { status ?? null, episodeId ?? null, ] as const, + /** + * The document sheet's visit picker: one paged direction from the + * document's date. Its own key because its page shape (offset steps inside + * a window) is not the list's. + */ + encounterPicker: (direction: string, pivot: string | null, term: string) => + ["encounters", "picker", direction, pivot ?? "", term] as const, encounter: (id: string) => ["encounters", "detail", id] as const, /** * The procedure history for one filter. Under the `["encounters"]` root, so diff --git a/src/lib/query-keys/illness.ts b/src/lib/query-keys/illness.ts index 61e5c2f35..c1f658aef 100644 --- a/src/lib/query-keys/illness.ts +++ b/src/lib/query-keys/illness.ts @@ -15,10 +15,14 @@ export const illnessKeys = { illness: () => ["illness"] as const, illnessEpisodes: (includeResolved: boolean) => ["illness", "episodes", includeResolved] as const, - // The visit form's episode picker, with its own page size (the illness - // pages read the route's default); a shared key would serve one to the other. - illnessEpisodesPicker: (limit: number) => - ["illness", "episodes-picker", limit] as const, + // The visit form's episode picker: one paged direction from the visit's + // date or a search, with its own page shape; a shared key would serve one + // read to the other. + illnessEpisodesPicker: ( + direction: string, + pivot: string | null, + term: string, + ) => ["illness", "episodes-picker", direction, pivot ?? "", term] as const, illnessEpisode: (id: string) => ["illness", "episode", id] as const, illnessDayLog: (episodeId: string, date: string) => ["illness", "day-log", episodeId, date] as const, diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index 45cdf949a..c15eb86e1 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -36,11 +36,12 @@ export const labKeys = { labAnalyteLink: (analyte: string) => ["lab-results", "analyte-link", analyte] as const, - // The visit form's lab picker: newest results, its own page size. Its own - // key because the Labs list reads a different page size, and a shared key - // would serve one read's page to the other. - labResultsPicker: (limit: number) => - ["lab-results", "picker", limit] as const, + // The visit form's lab picker: one paged direction from the visit's date + // (`older` / `newer`) or a search across the whole history. Its own key + // because the Labs list reads other pages, and a shared key would serve + // one read's page to the other. + labResultsPicker: (direction: string, pivot: string | null, term: string) => + ["lab-results", "picker", direction, pivot ?? "", term] as const, // v1.25 — paginated (offset) reading feed for the biomarker detail page. // Distinct from `labResultsList` (single-page key) so the infinite-query diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 9955204dd..4d8126699 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -164,6 +164,11 @@ export const encounterListQuerySchema = z */ episodeId: id.optional(), limit: z.coerce.number().int().min(1).max(200).optional(), + /** + * Rows to skip in each arm, for a picker that pages a long history in + * `limit`-sized steps inside a date window. + */ + offset: z.coerce.number().int().min(0).max(100_000).optional(), }) .strict() .refine((q) => !(q.from && q.to) || q.from <= q.to, { diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index b96933709..afc5e5a83 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -136,6 +136,17 @@ export type IllnessEpisodeResolve = z.infer; export const illnessEpisodeListQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).optional(), includeResolved: z.union([z.literal("true"), z.literal("false")]).optional(), + /** + * Paging and a date window over the onset, for a picker that opens on the + * episodes around a visit and pages older or newer from there. + */ + offset: z.coerce.number().int().min(0).max(100_000).optional(), + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + /** Onset order; newest first by default. */ + sortDir: z.enum(["asc", "desc"]).optional(), + /** Case-insensitive substring over the episode's label. */ + q: z.string().trim().min(1).max(120).optional(), }); /* ── day-log capture (one row per day of an episode) ─────────────── */ diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index d7c2ea3c3..6bbc3ef0d 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -284,6 +284,11 @@ export const listLabResultsSchema = z.object({ biomarkerId: z.string().trim().min(1).max(64).optional(), analyte: z.string().trim().min(1).max(120).optional(), panel: z.string().trim().min(1).max(120).optional(), + /** + * Case-insensitive substring over the analyte and the panel name, so a + * picker can find one result in a long history without paging to it. + */ + q: z.string().trim().min(1).max(120).optional(), from: z .string() .datetime() diff --git a/tests/integration/visit-link-picker-long-history.test.ts b/tests/integration/visit-link-picker-long-history.test.ts new file mode 100644 index 000000000..b9d286a4d --- /dev/null +++ b/tests/integration/visit-link-picker-long-history.test.ts @@ -0,0 +1,311 @@ +/** + * A visit can link a record from anywhere in a long history. + * + * The visit form's lab picker loaded the newest 200 results and offered only + * those, the condition picker the newest 100 conditions. On a record with a + * long history everything older could not be linked: a 2019 visit could not + * reach its own 2019 lab panel. The pickers now open on a window around the + * visit's date and page and search through the list routes. + * + * Everything here goes through the shipped route exports with the requests + * the pickers build (`picker-requests.ts`), against a record of 1,200 lab + * results spread over seven years, so the window, the search and the paging + * are proven against real SQL, and the link is saved and read back. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import { + GET as listEncounters, + POST as createEncounter, +} from "@/app/api/encounters/route"; +import { GET as listEpisodes } from "@/app/api/illness/episodes/route"; +import { GET as listLabs } from "@/app/api/labs/route"; +import { + encounterPickerUrl, + episodePickerUrl, + labPickerUrl, +} from "@/components/links/picker-requests"; +import { + nextOffset, + pickerPivot, + type PickerPageRequest, +} from "@/components/links/use-link-picker-pages"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +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), +})); + +const OWNER_ID = "long-history-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; +/** Historical on purpose: the visit and its panel sit years back. */ +const VISIT_AT = "2019-04-03T09:30:00.000Z"; +const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z"); +const RESULT_COUNT = 1_200; + +async function seedOwner() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "long-history", + email: "long-history@example.test", + timezone: "Europe/Berlin", + modulePreferencesJson: { labs: true, illness: true }, + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: new Date(Date.now() + 7 * DAY_MS), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +/** + * 1,200 glucose results, one every two days back from today, plus the one + * ferritin result drawn on the morning of the 2019 visit. + */ +async function seedLabs(): Promise { + const prisma = getPrismaClient(); + const now = Date.now(); + await prisma.labResult.createMany({ + data: Array.from({ length: RESULT_COUNT }, (_, i) => ({ + userId: OWNER_ID, + panel: "Metabolic", + analyte: "Glucose", + value: 90 + (i % 10), + unit: "mg/dL", + takenAt: new Date(now - (i + 1) * 2 * DAY_MS), + })), + }); + const ferritin = await prisma.labResult.create({ + data: { + userId: OWNER_ID, + panel: "Iron studies", + analyte: "Ferritin", + value: 54, + unit: "ng/mL", + takenAt: FERRITIN_AT, + }, + }); + return ferritin.id; +} + +async function get( + handler: (request: NextRequest) => Promise, + url: string, +): Promise { + const response = await handler(new NextRequest(`http://localhost${url}`)); + expect(response.status, url).toBe(200); + return ((await response.json()) as { data: T }).data; +} + +interface LabPage { + results: Array<{ id: string; analyte: string; takenAt: string }>; + meta: { total: number }; +} + +const labPage = (request: PickerPageRequest) => + get(listLabs as never, labPickerUrl(request)); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedOwner(); +}); + +describe("the visit form's lab picker on a 1,200-result record", () => { + it("the old newest-200 page never reached the 2019 result", async () => { + const ferritinId = await seedLabs(); + const page = await get( + listLabs as never, + "/api/labs?limit=200&offset=0&sortDir=desc", + ); + expect(page.meta.total).toBe(RESULT_COUNT + 1); + expect(page.results.some((row) => row.id === ferritinId)).toBe(false); + }); + + it("opens on the visit's own date, with the 2019 result on the first page", async () => { + const ferritinId = await seedLabs(); + const pivot = pickerPivot(VISIT_AT); + const first = await labPage({ + direction: "older", + pivot, + term: "", + cursor: null, + }); + expect(first.results.some((row) => row.id === ferritinId)).toBe(true); + // Nothing after the window's split is on it. + for (const row of first.results) { + expect(new Date(row.takenAt).getTime()).toBeLessThanOrEqual( + new Date(pivot!).getTime(), + ); + } + + // "Load newer" pages forward from the split, oldest first, without + // repeating a row. + const seen = new Set(first.results.map((row) => row.id)); + let cursor: string | null = null; + let newerRows = 0; + let previous = new Date(pivot!).getTime(); + for (let guard = 0; guard < 20; guard++) { + const page = await labPage({ + direction: "newer", + pivot, + term: "", + cursor, + }); + for (const row of page.results) { + expect(seen.has(row.id)).toBe(false); + seen.add(row.id); + const at = new Date(row.takenAt).getTime(); + expect(at).toBeGreaterThanOrEqual(previous); + previous = at; + } + newerRows += page.results.length; + cursor = nextOffset(cursor, page.results.length); + if (cursor === null) break; + } + // Older window plus every newer page cover the whole record once. + expect(seen.size).toBe(RESULT_COUNT + 1); + expect(newerRows).toBeGreaterThan(0); + }); + + it("a search finds the 2019 result among 1,200 others, by analyte and by panel", async () => { + const ferritinId = await seedLabs(); + for (const term of ["ferritin", "iron stud"]) { + const page = await labPage({ + direction: "search", + pivot: null, + term, + cursor: null, + }); + expect(page.results.map((row) => row.id)).toEqual([ferritinId]); + } + }); + + it("the found result links to the visit and reads back on it", async () => { + const ferritinId = await seedLabs(); + const created = await createEncounter( + new Request("http://localhost/api/encounters", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + occurredAt: VISIT_AT, + status: "DONE", + kind: "ROUTINE", + labResultIds: [ferritinId], + }), + }) as never, + ); + expect(created.status).toBe(201); + + const list = await get<{ + past: Array<{ + id: string; + links?: { labResults: Array<{ id: string; label: string | null }> }; + }>; + }>( + listEncounters as never, + encounterPickerUrl({ + direction: "older", + pivot: pickerPivot(VISIT_AT), + term: "", + cursor: null, + }), + ); + expect(list.past).toHaveLength(1); + expect(list.past[0]!.links?.labResults).toEqual([ + expect.objectContaining({ + id: ferritinId, + label: "Iron studies · Ferritin", + }), + ]); + }); +}); + +describe("the condition picker's window and search", () => { + it("pages the episodes around a date and finds one by name", async () => { + const prisma = getPrismaClient(); + const now = Date.now(); + await prisma.illnessEpisode.createMany({ + data: Array.from({ length: 150 }, (_, i) => ({ + userId: OWNER_ID, + label: `Cold ${i}`, + type: "INFECTION" as const, + onsetAt: new Date(now - (i + 1) * 10 * DAY_MS), + })), + }); + const old = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Tendinitis", + type: "INJURY", + onsetAt: new Date("2019-03-20T00:00:00.000Z"), + }, + }); + + const pivot = pickerPivot(VISIT_AT); + const around = await get>( + listEpisodes as never, + episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }), + ); + expect(around[0]!.id).toBe(old.id); + + const found = await get>( + listEpisodes as never, + episodePickerUrl({ + direction: "search", + pivot: null, + term: "tendin", + cursor: null, + }), + ); + expect(found.map((row) => row.id)).toEqual([old.id]); + + // Offset paging walks the whole newer side without a repeat. + const ids = new Set(); + let cursor: string | null = null; + for (let guard = 0; guard < 5; guard++) { + const page: Array<{ id: string }> = await get( + listEpisodes as never, + episodePickerUrl({ direction: "newer", pivot, term: "", cursor }), + ); + for (const row of page) { + expect(ids.has(row.id)).toBe(false); + ids.add(row.id); + } + cursor = nextOffset(cursor, page.length); + if (cursor === null) break; + } + expect(ids.size).toBe(150); + }); +}); From 2d03eba5879f8c1e515e2b9918cdaf51052518ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:26:49 +0200 Subject: [PATCH 09/40] fix(google-health): resume a backfill inside a collection and run it in short slices A backfill noted progress only once a whole collection was read to the end. A heart-rate history of years that one attempt could not finish (its budget, a rate limit it could not wait out, a single failed request among thousands of pages) started the next attempt from the newest page again, finished nothing, and failed with "marker not stamped" on every attempt. - Every spot-reading collection read page by page now records, after each page it writes, the oldest sample it has written (backfillProgress.cursors). The next attempt asks Google only for samples at or beyond that edge. The edge is held back once a page's write fails, so no rows are skipped. - The backfill runs in slices of five minutes or 500 pages. A slice that finished a collection or moved an edge continues with the next one; only a slice that moved nothing fails, and its error names each unfinished collection and why the slice stopped. The slice ceiling is derived from the collections and the page ceilings of the resumable ones. - A rate limit Google will not lift within a minute ends the slice instead of failing the walk, and the next slice starts five minutes later. Server errors, timeouts and dropped connections are retried three times. - The heart-rate stream is read last, so the light collections finish in the first slice. - The hourly sync waits up to eight minutes for a running slice instead of skipping the account for as long as the import runs. - Each slice annotates googleHealth.backfill.slice with pages, elapsed time, stop reason, per-collection status and edges, and reports progress on the job.progress line. --- docs/integrations/google-health.md | 83 ++++- prisma/schema.prisma | 6 +- .../backfill-resume-progress.test.ts | 192 ++++++++++- .../__tests__/rollup-request.test.ts | 3 +- .../sync-metrics-dense-pages.test.ts | 4 + .../__tests__/sync-metrics-failsoft.test.ts | 4 + .../__tests__/walk-resume-and-retry.test.ts | 192 +++++++++++ src/lib/google-health/client.ts | 267 +++++++++++++-- src/lib/google-health/mappers.ts | 15 + src/lib/google-health/sync-core.ts | 307 ++++++++++++++++-- src/lib/google-health/sync-lock.ts | 14 +- src/lib/google-health/sync-metrics.ts | 75 ++++- .../__tests__/google-health-backfill.test.ts | 88 ++++- src/lib/jobs/google-health-backfill.ts | 211 ++++++++---- src/lib/jobs/reminder/google-health-sync.ts | 11 +- .../google-health-backfill-rate-limit.test.ts | 92 ++++-- 16 files changed, 1399 insertions(+), 165 deletions(-) create mode 100644 src/lib/google-health/__tests__/walk-resume-and-retry.test.ts diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 7fc61e208..b9bf5e095 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -122,22 +122,87 @@ The Google Cloud client id/secret are per-user (Settings), not env vars. ## Backfill, the hourly sync and Google's rate limit -Only one sync runs per account at a time. While the history backfill that -follows a connect is running, the hourly sync leaves that account alone, and -**Sync now** reports that a sync is already in progress and shows its progress -instead of starting a second one. +Only one sync runs per account at a time. **Sync now** reports that a sync is +already in progress while the history backfill that follows a connect is +running, and shows its progress instead of starting a second one. + +The backfill reads the account's whole history, and for a watch that records +heart rate every minute that is a thousand-odd pages per year. It runs in +slices: a slice reads for up to five minutes or 500 pages, writes what it +read, notes how far it got and lets go of the account. The next slice starts +a minute later where the last one stopped. Two kinds of note are kept: a data +type read to the end is not read again, and a data type read page by page +(heart rate, weight, body fat, blood glucose, core temperature) keeps the +time of the oldest reading it has written, so the next slice asks Google only +for what is older. A restart or an update in the middle loses at most the page +that was being read. + +The hourly sync waits for the slice that is running (up to eight minutes) +instead of skipping the account, so recent data keeps arriving while an old +history is still coming in. Google limits how many requests one account may make per minute. When a sync -reaches that limit, Google answers `429`, and HealthLog waits and repeats the -request; the connection is never marked as needing a reconnect because of it. -Access tokens last an hour, and a long backfill renews its token as it goes. -A backfill that stops part-way (a restart, the job's time budget) picks up -with the data types it has not finished yet on its next attempt. +reaches that limit Google answers `429`, and HealthLog waits and repeats the +request. If Google asks for a longer wait than a minute, the slice ends there +and the next one starts five minutes later. A server error (`500`, `502`, +`503`, `504`), a request that times out and a dropped connection are repeated +three times before they count as a failure. None of these mark the connection +as needing a reconnect. Access tokens last an hour, and a long backfill renews +its token as it goes. + +A slice that moved nothing forward (no data type finished, no reading older +than before) is a real failure: the job fails with the reason, pg-boss retries +it a few times, and after that the next restart offers the backfill again. +Everything already written stays. If an older version left your Google Health card asking for a reconnect after a backfill, the first sync after updating checks the stored grant and resumes on its own when the grant is still valid. +### Reading the backfill in the logs + +Every slice writes one log line when it ends, with +`"background":{"task_name":"job.integration_backfill"}`. The parts worth +reading: + +- `meta.kind` is `google-health-backfill`, `meta.slice` the slice number + (0 for the first), `meta.job_id` the pg-boss job. +- `external_calls` lists the requests the slice made to Google, one entry per + request: `method` names the data type and page (`fetchHeartRate(page=212)`), + `status` the HTTP status, `duration_ms` how long it took. A request that was + repeated carries a `note` (`rate_limited_retrying_in_ms=4000`, + `server_error_retrying_in_ms=2000`, `no_response_retrying_in_ms=2000`, + `token_rejected_retrying_with_fresh_token`); one that failed for good + carries an `error`. Successful requests are listed up to 60 per line and + everything up to 200; `external_calls_omitted` counts the rest. +- `meta["googleHealth.backfill.slice"]` sums the slice up: + - `pages`, `elapsedMs` and `imported`: what the slice read, how long it + took, how many readings it wrote. + - `stopReason`: why it ended early. `time_budget` and `page_budget` are the + normal end of a slice, `rate_limited` means Google would not take more + requests for now, `job_budget` that the job itself ran out of time. + `null` means the slice read everything it started. + - `collectionsDone` of `collectionsTotal`: data types read to the end so + far. + - `collections`: one entry per data type the slice touched, with its + `status` (`done`, `stopped` short on a budget or a rate limit, + `deferred` to the next slice, or `failed`), its `pages`, and for the + page-by-page types + `edgeFrom` and `edgeTo`, the oldest reading written before and after the + slice. + - `edges`: where every unfinished page-by-page data type stands now. +- `action.name` is `google_health.backfill.continued` for a slice that made + progress and queued the next one, `google_health.backfill.complete` for the + one that finished the history, and `google_health.backfill.stuck` for one + that moved nothing. A stuck slice also fails the job with a message that + names each unfinished data type and its status, for example + `no progress in slice 4, stopped on rate_limited; fetchHeartRate:stopped`. + +While a slice runs, the usual `job.progress` line for the +`integration-backfill-admission` queue reports +`google_health_backfill_slice`, `google_health_backfill_pages` and +`google_health_backfill_collections_done` every two minutes. + ## The 7-day re-consent caveat While the OAuth consent screen stays in **Testing** publishing mode, Google diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9834872e9..95b316c3a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4410,8 +4410,10 @@ model GoogleHealthConnection { /// pre-fix externalId (a re-scored night minted parallel copies). Null = /// repair pending (or the connection predates the fix). sleepRepairedAt DateTime? @map("sleep_repaired_at") - /// The collections the in-flight history backfill has already walked to - /// the end (`{ done: string[] }`), so a retry resumes with the rest + /// How far the in-flight history backfill has got + /// (`{ done: string[], cursors: { [collection]: { at, order, pages } } }`): + /// the collections it walked to the end, and for each collection read page + /// by page the edge it has written up to, so the next slice resumes there /// instead of starting over. Null once the backfill completes, and reset /// on every reconnect. backfillProgress Json? @map("backfill_progress") diff --git a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts index aa80d605d..69e9715e9 100644 --- a/src/lib/google-health/__tests__/backfill-resume-progress.test.ts +++ b/src/lib/google-health/__tests__/backfill-resume-progress.test.ts @@ -20,9 +20,17 @@ vi.mock("@/lib/logging/context", () => ({ })); import { + readGoogleHealthBackfillCursors, + recordGoogleHealthBackfillPage, runGoogleHealthCollection, runWithGoogleHealthBackfillResume, + type GoogleHealthBackfillCursor, } from "../sync-core"; +import { + googleHealthSliceBudget, + runWithGoogleHealthClientOutcome, + runWithGoogleHealthStop, +} from "../client"; beforeEach(() => { vi.clearAllMocks(); @@ -41,7 +49,9 @@ describe("runGoogleHealthCollection under a backfill", () => { expect([...done]).toEqual(["fetchSteps", "sleep"]); expect(prismaMock.googleHealthConnection.update).toHaveBeenCalledWith({ where: { userId: "u1" }, - data: { backfillProgress: { done: ["fetchSteps", "sleep"] } }, + data: { + backfillProgress: { done: ["fetchSteps", "sleep"], cursors: {} }, + }, }); }); @@ -63,3 +73,183 @@ describe("runGoogleHealthCollection under a backfill", () => { ); }); }); + +/** + * #1220 — a collection read page by page keeps its place inside the + * collection, not only once it is finished. A heart-rate history of years + * that one attempt could not finish used to start over from its newest page + * on every attempt. + */ +describe("the resume edge of a page-by-page collection", () => { + const at = (iso: string) => new Date(iso); + + it("moves the edge to the oldest sample of each page and persists it", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const cursors = new Map(); + + const { advanced } = await runWithGoogleHealthBackfillResume( + "u1", + new Set(["sleep"]), + async () => { + await recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at("2026-10-01T12:00:00Z"), + last: at("2026-10-01T00:00:00Z"), + }); + await recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at("2026-09-30T23:59:00Z"), + last: at("2026-09-30T08:00:00Z"), + }); + }, + cursors, + ); + + expect(cursors.get("fetchHeartRate")).toEqual({ + at: "2026-09-30T08:00:00.000Z", + order: "desc", + pages: 2, + }); + expect(advanced.get("fetchHeartRate")).toBeNull(); + const last = prismaMock.googleHealthConnection.update.mock.calls.at( + -1, + )![0] as { data: { backfillProgress: unknown } }; + expect(last.data.backfillProgress).toEqual({ + done: ["sleep"], + cursors: { + fetchHeartRate: { + at: "2026-09-30T08:00:00.000Z", + order: "desc", + pages: 2, + }, + }, + }); + // And it reads back the way it was written. + expect( + readGoogleHealthBackfillCursors(last.data.backfillProgress).get( + "fetchHeartRate", + ), + ).toEqual(cursors.get("fetchHeartRate")); + }); + + it("follows a walk that runs oldest first", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const cursors = new Map(); + await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + () => + recordGoogleHealthBackfillPage("fetchWeight", { + first: at("2024-01-01T00:00:00Z"), + last: at("2024-03-01T00:00:00Z"), + }), + cursors, + ); + expect(cursors.get("fetchWeight")).toMatchObject({ + at: "2024-03-01T00:00:00.000Z", + order: "asc", + }); + }); + + it("does not count a page that moved nothing as progress", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const cursors = new Map([ + [ + "fetchHeartRate", + { at: "2026-01-01T00:00:00.000Z", order: "desc", pages: 7 }, + ], + ]); + const { advanced } = await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + () => + // The edge sample itself, read again: no further than before. + recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at("2026-01-01T00:00:00Z"), + last: at("2026-01-01T00:00:00Z"), + }), + cursors, + ); + expect(advanced.size).toBe(0); + expect(cursors.get("fetchHeartRate")).toMatchObject({ + at: "2026-01-01T00:00:00.000Z", + pages: 8, + }); + }); + + it("counts no progress when the edge cannot be written", async () => { + prismaMock.googleHealthConnection.update.mockRejectedValue( + new Error("connection reset"), + ); + const cursors = new Map(); + const { advanced } = await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + () => + recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at("2026-10-01T12:00:00Z"), + last: at("2026-10-01T00:00:00Z"), + }), + cursors, + ); + expect(advanced.size).toBe(0); + expect(cursors.size).toBe(0); + expect(warnings.some((w) => w.includes("progress write failed"))).toBe( + true, + ); + }); + + it("clears a finished collection's edge with the same write that marks it done", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const cursors = new Map([ + [ + "fetchHeartRate", + { at: "2026-01-01T00:00:00.000Z", order: "desc", pages: 7 }, + ], + ]); + const done = new Set(); + await runWithGoogleHealthBackfillResume( + "u1", + done, + () => runGoogleHealthCollection("fetchHeartRate", async () => 1), + cursors, + ); + expect([...done]).toEqual(["fetchHeartRate"]); + expect(cursors.size).toBe(0); + expect(prismaMock.googleHealthConnection.update).toHaveBeenCalledWith({ + where: { userId: "u1" }, + data: { backfillProgress: { done: ["fetchHeartRate"], cursors: {} } }, + }); + }); + + it("leaves a collection for the next slice once the slice has no budget left", async () => { + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + const walk = vi.fn(async () => 5); + const budget = googleHealthSliceBudget(0, 100); + const { result } = await runWithGoogleHealthClientOutcome(() => + runWithGoogleHealthBackfillResume("u1", new Set(), () => + runWithGoogleHealthStop( + () => false, + () => runGoogleHealthCollection("fetchHeartRate", walk), + budget, + ), + ), + ); + expect(walk).not.toHaveBeenCalled(); + expect(result.collections).toEqual([ + { key: "fetchHeartRate", status: "deferred", pages: 0, durationMs: 0 }, + ]); + expect(budget.stopReason).toBe("time_budget"); + }); + + it("marks the cycle incomplete when it leaves a collection for later", async () => { + const { outcome } = await runWithGoogleHealthClientOutcome(() => + runWithGoogleHealthBackfillResume("u1", new Set(), () => + runWithGoogleHealthStop( + () => false, + () => runGoogleHealthCollection("fetchWeight", async () => 0), + googleHealthSliceBudget(0, 100), + ), + ), + ); + expect(outcome.truncated).toBe(true); + }); +}); diff --git a/src/lib/google-health/__tests__/rollup-request.test.ts b/src/lib/google-health/__tests__/rollup-request.test.ts index 54e23c31b..bed00a773 100644 --- a/src/lib/google-health/__tests__/rollup-request.test.ts +++ b/src/lib/google-health/__tests__/rollup-request.test.ts @@ -208,7 +208,8 @@ describe("fetchDailyRollUp — chunk walk + conservative fallback", () => { }); it("propagates a non-400 first-request failure without a fallback walk", async () => { - safeFetchMock.mockResolvedValueOnce(jsonResponse(500, {})); + // A 403 is neither the fallback's 400 nor a server error worth repeating. + safeFetchMock.mockResolvedValueOnce(jsonResponse(403, {})); await expect( fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", { start: new Date("2026-06-01T00:00:00Z"), diff --git a/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts b/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts index b4bf66c8e..becd43db9 100644 --- a/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts +++ b/src/lib/google-health/__tests__/sync-metrics-dense-pages.test.ts @@ -32,6 +32,10 @@ vi.mock("../sync-core", () => ({ (_userId: string, info: { accessToken: string }) => async () => info.accessToken, runGoogleHealthCollection: (_key: string, fn: () => Promise) => fn(), + // Outside a backfill: no resume edge, nothing to record. + googleHealthBackfillCursor: () => null, + googleHealthHardFailureCount: () => 0, + recordGoogleHealthBackfillPage: vi.fn(async () => {}), })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts index 0a4c021e0..1f0ce79d2 100644 --- a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts @@ -58,6 +58,10 @@ vi.mock("../sync-core", () => ({ (_userId: string, info: { accessToken: string }) => async () => info.accessToken, runGoogleHealthCollection: (_key: string, fn: () => Promise) => fn(), + // Outside a backfill: no resume edge, nothing to record. + googleHealthBackfillCursor: () => null, + googleHealthHardFailureCount: () => 0, + recordGoogleHealthBackfillPage: vi.fn(async () => {}), upsertGoogleHealthMeasurements: vi.fn(async () => ({ imported: 0, touched: [], diff --git a/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts b/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts new file mode 100644 index 000000000..ecaba31c6 --- /dev/null +++ b/src/lib/google-health/__tests__/walk-resume-and-retry.test.ts @@ -0,0 +1,192 @@ +/** + * #1220 — the list walk under a backfill slice. + * + * - A server error or a request that got no answer is repeated a few times + * instead of ending a walk of thousands of pages. + * - A resumable walk asks only for the samples beyond the edge an earlier + * attempt reached. + * - Inside a slice, a resumable walk stops on the slice's budget and on a + * rate limit it cannot wait out, and reports itself truncated rather than + * failing; a walk that is not resumable runs on under the job's limit. + */ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() })); +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, safeFetch: safeFetchMock }; +}); +vi.mock("@/lib/logging/context", () => ({ + annotate: () => {}, + getEvent: () => null, +})); + +import { SafeFetchError } from "@/lib/safe-fetch"; +import { + GOOGLE_HEALTH_DATA_TYPES, + GOOGLE_HEALTH_TRANSIENT_RETRY, + forEachDataPointPage, + googleHealthSliceBudget, + runWithGoogleHealthClientOutcome, + runWithGoogleHealthStop, +} from "../client"; +import { GoogleHealthApiError } from "../response-classifier"; + +function page(n: number, next: boolean, status = 200, headers = {}): Response { + return new Response( + JSON.stringify( + status === 200 + ? { + dataPoints: [{ n }], + ...(next ? { nextPageToken: String(n + 1) } : {}), + } + : { error: { code: status } }, + ), + { status, headers: { "content-type": "application/json", ...headers } }, + ); +} + +const baseDelay = GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs; +beforeEach(() => { + safeFetchMock.mockReset(); + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; +}); +afterAll(() => { + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = baseDelay; +}); + +const walk = (query: Parameters[3] = {}) => + runWithGoogleHealthClientOutcome(() => + forEachDataPointPage( + GOOGLE_HEALTH_DATA_TYPES.heartRate, + "token", + "fetchHeartRate", + query, + () => {}, + ), + ); + +describe("a server error or a lost request mid-walk", () => { + it("is repeated and the walk goes on", async () => { + safeFetchMock + .mockResolvedValueOnce(page(0, true)) + .mockResolvedValueOnce(page(0, false, 503)) + .mockRejectedValueOnce(new SafeFetchError("aborted", "timeout")) + .mockResolvedValueOnce(page(1, false)); + + const { outcome } = await walk(); + + expect(safeFetchMock).toHaveBeenCalledTimes(4); + expect(outcome).toMatchObject({ pages: 2, truncated: false }); + }); + + it("fails the walk once the retries are spent", async () => { + safeFetchMock.mockResolvedValue(page(0, false, 500)); + await expect(walk()).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(safeFetchMock).toHaveBeenCalledTimes( + GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries + 1, + ); + }); + + it("does not repeat an error that is not about the server", async () => { + safeFetchMock.mockRejectedValue(new TypeError("bad request init")); + await expect(walk()).rejects.toBeInstanceOf(TypeError); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("a resumable walk", () => { + it("asks only for the samples at or beyond the recorded edge", async () => { + safeFetchMock.mockResolvedValue(page(0, false)); + await walk({ + resumable: true, + edge: { at: new Date("2025-03-01T08:00:00Z"), order: "desc" }, + }); + const url = new URL(safeFetchMock.mock.calls[0]![0] as string); + expect(url.searchParams.get("filter")).toBe( + 'heart_rate.sample_time.physical_time <= "2025-03-01T08:00:00.000Z"', + ); + }); + + it("stops on the slice's page budget and reports itself truncated", async () => { + safeFetchMock.mockImplementation(async (url: string) => + page(Number(new URL(url).searchParams.get("pageToken") ?? 0), true), + ); + const budget = googleHealthSliceBudget(60_000, 3); + const { outcome } = await runWithGoogleHealthStop( + () => false, + () => walk({ resumable: true }), + budget, + ); + expect(safeFetchMock).toHaveBeenCalledTimes(3); + expect(outcome.truncated).toBe(true); + expect(budget.stopReason).toBe("page_budget"); + }); + + it("starts the slice's clock at the first walk, not when the slice was set up", async () => { + safeFetchMock.mockResolvedValue(page(0, false)); + let clock = 0; + const budget = googleHealthSliceBudget(1_000, 500, () => clock); + // The slice waited ten minutes for the account's lock before walking. + clock = 600_000; + const { outcome } = await runWithGoogleHealthStop( + () => false, + () => walk({ resumable: true }), + budget, + ); + expect(outcome).toMatchObject({ pages: 1, truncated: false }); + expect(budget.deadline).toBe(601_000); + }); + + it("ends the slice on a rate limit it cannot wait out, without failing", async () => { + safeFetchMock + .mockResolvedValueOnce(page(0, true)) + .mockResolvedValueOnce(page(1, true, 429, { "retry-after": "3600" })); + const budget = googleHealthSliceBudget(60_000, 500); + const { outcome } = await runWithGoogleHealthStop( + () => false, + () => walk({ resumable: true }), + budget, + ); + expect(outcome).toMatchObject({ pages: 1, truncated: true }); + expect(budget.stopReason).toBe("rate_limited"); + }); + + it("still fails on a rate limit outside a slice", async () => { + safeFetchMock.mockResolvedValue( + page(0, true, 429, { "retry-after": "3600" }), + ); + await expect(walk({ resumable: true })).rejects.toBeInstanceOf( + GoogleHealthApiError, + ); + }); +}); + +describe("a walk that is not resumable", () => { + it("runs past the slice's budget to its end", async () => { + safeFetchMock.mockImplementation(async (url: string) => { + const n = Number(new URL(url).searchParams.get("pageToken") ?? 0); + return page(n, n < 5); + }); + const budget = googleHealthSliceBudget(0, 1); + const { outcome } = await runWithGoogleHealthStop( + () => false, + () => walk(), + budget, + ); + expect(safeFetchMock).toHaveBeenCalledTimes(6); + expect(outcome.truncated).toBe(false); + }); + + it("still stops on the job's own budget", async () => { + safeFetchMock.mockImplementation(async () => page(0, true)); + let asked = 0; + const { outcome } = await runWithGoogleHealthStop( + () => ++asked > 2, + () => walk(), + googleHealthSliceBudget(60_000, 500), + ); + expect(safeFetchMock).toHaveBeenCalledTimes(2); + expect(outcome.truncated).toBe(true); + }); +}); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 3bdab3aec..1bcdee440 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -36,7 +36,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createHash, randomBytes } from "node:crypto"; import { annotate, getEvent } from "@/lib/logging/context"; -import { safeFetch } from "@/lib/safe-fetch"; +import { SafeFetchError, safeFetch } from "@/lib/safe-fetch"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { GoogleHealthApiError, @@ -134,18 +134,140 @@ export async function runWithGoogleHealthClientOutcome( * reaching the page ceiling: the resource is incomplete, the cycle is not * stamped, and the reason shown is that only part of the history was * processed. Outside such a scope the walks never stop early. + * + * A backfill slice adds a second, tighter limit (`GoogleHealthSliceBudget`): + * minutes and pages rather than the job's hours. Only a walk that keeps its + * place across attempts (`resumable`) honours it, because only such a walk + * loses nothing by stopping; a walk that would start over from its first page + * runs on to the end under the job's limit alone. */ -const stopStorage = new AsyncLocalStorage<() => boolean>(); +interface GoogleHealthWalkLimits { + shouldStop: () => boolean; + slice?: GoogleHealthSliceBudget; +} -function stopRequested(): boolean { - return stopStorage.getStore()?.() === true; +/** Why a backfill slice stopped walking. */ +export type GoogleHealthSliceStopReason = + "time_budget" | "page_budget" | "rate_limited" | "job_budget"; + +/** + * One backfill slice's share of the work: a span of wall-clock time and a + * page count, both well inside the job's own expiry. The clock starts the + * first time a walk asks, which is after the slice has the account's lock: a + * slice that waited for an hourly sync to finish does not start out with its + * time already spent. `pages` counts every page the slice's walks have read; + * `stopReason` is set the first time a limit is reached and stays set, so + * every walk after it stops too. + */ +export interface GoogleHealthSliceBudget { + ms: number; + /** When the slice's time is up; null until the first walk asks. */ + deadline: number | null; + maxPages: number; + pages: number; + stopReason: GoogleHealthSliceStopReason | null; + now: () => number; +} + +/** A fresh slice budget of `ms` milliseconds and `maxPages` pages. */ +export function googleHealthSliceBudget( + ms: number, + maxPages: number, + now: () => number = Date.now, +): GoogleHealthSliceBudget { + return { + ms, + deadline: null, + maxPages, + pages: 0, + stopReason: null, + now, + }; +} + +const stopStorage = new AsyncLocalStorage(); + +/** Whether the slice in scope has used its budget (and why, once it has). */ +function sliceExhausted(slice: GoogleHealthSliceBudget): boolean { + if (slice.stopReason) return true; + slice.deadline ??= slice.now() + slice.ms; + if (slice.now() >= slice.deadline) slice.stopReason = "time_budget"; + else if (slice.pages >= slice.maxPages) slice.stopReason = "page_budget"; + return slice.stopReason !== null; +} + +/** + * Whether a walk should stop before its next request. Every walk stops on the + * job's limit; a resumable one also stops on the slice's. + */ +function stopRequested(resumable = false): boolean { + const limits = stopStorage.getStore(); + if (!limits) return false; + if (limits.shouldStop()) { + if (limits.slice && !limits.slice.stopReason) { + limits.slice.stopReason = "job_budget"; + } + return true; + } + return ( + resumable && limits.slice !== undefined && sliceExhausted(limits.slice) + ); +} + +/** + * Whether the backfill slice in scope has used its budget. A collection that + * has not started yet waits for the next slice rather than starting now. + * False outside a slice. + */ +export function googleHealthSliceExhausted(): boolean { + const limits = stopStorage.getStore(); + if (!limits) return false; + if (limits.shouldStop()) { + if (limits.slice && !limits.slice.stopReason) { + limits.slice.stopReason = "job_budget"; + } + return true; + } + return limits.slice !== undefined && sliceExhausted(limits.slice); +} + +/** Pages the slice in scope has read so far (0 outside a slice). */ +export function googleHealthSlicePagesRead(): number { + return stopStorage.getStore()?.slice?.pages ?? 0; +} + +/** Count one page read against the slice in scope, if any. */ +function noteSlicePage(): void { + const slice = stopStorage.getStore()?.slice; + if (slice) slice.pages += 1; +} + +/** End the slice in scope for `reason` (no-op outside a slice). */ +function endSlice(reason: GoogleHealthSliceStopReason): void { + const slice = stopStorage.getStore()?.slice; + if (slice && !slice.stopReason) slice.stopReason = reason; +} + +/** Whether a resumable walk can stop and keep its place here. */ +function inSlice(): boolean { + return stopStorage.getStore()?.slice !== undefined; } export async function runWithGoogleHealthStop( shouldStop: () => boolean, fn: () => Promise, + slice?: GoogleHealthSliceBudget, ): Promise { - return stopStorage.run(shouldStop, fn); + return stopStorage.run({ shouldStop, slice }, fn); +} + +/** + * Mark the resource in scope truncated without a walk: a collection a slice + * left for the next one. The cycle then reports itself incomplete, so the + * backfill does not mistake a skipped collection for an empty one. + */ +export function noteGoogleHealthDeferred(): void { + notePagination(0, 0, true); } export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4"; @@ -527,6 +649,7 @@ export function resolveGoogleHealthUserId( // camelCase nested objects (`sampleTime.physicalTime`, // `interval.startTime`, `civilStartTime.date`). // + // proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric // extractor coerces numeric strings before the finite check. @@ -553,6 +676,9 @@ export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25; */ export const GOOGLE_HEALTH_DENSE_MAX_PAGES = 10_000; +/** Page ceiling for every other list walk. */ +export const GOOGLE_HEALTH_DEFAULT_MAX_PAGES = 1000; + /** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ interface GoogleHealthDataPointPage { dataPoints?: GoogleHealthDataPoint[]; @@ -578,6 +704,20 @@ interface DataPointQuery { * surfaces it so a live account reports which grammar Google accepted. */ onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void; + /** + * The walk keeps its place across attempts (a backfill's resume cursor), so + * it may stop on the backfill slice's budget and on a rate limit it cannot + * wait out, and report itself truncated instead of failing: the next slice + * picks up from the edge it recorded. + */ + resumable?: boolean; + /** + * Where an earlier attempt of this walk got to: the oldest sample it wrote + * when the walk ran newest first (`desc`), the newest when it ran oldest + * first (`asc`). The walk asks only for the samples at or beyond it; the + * one sample on the edge itself is read again and overwrites in place. + */ + edge?: { at: Date; order: "desc" | "asc" }; } const pad2 = (n: number): string => String(n).padStart(2, "0"); @@ -797,6 +937,21 @@ export function googleHealthRetryDelayMs( return undefined; } +/** + * How a data request rides out a server that did not answer properly: a + * 500, 502, 503 or 504, a timeout, or a broken connection. A short history + * reads a handful of pages and rarely meets one; a full-history heart-rate + * walk reads thousands, and a single one used to end the walk and throw away + * everything it had read. Three tries more, 2, 4 and 8 s apart (or the + * `Retry-After` a 503 names, up to a minute), before the request fails. + */ +export const GOOGLE_HEALTH_TRANSIENT_RETRY = { + maxRetries: 3, + baseDelayMs: 2_000, +}; + +const GOOGLE_HEALTH_TRANSIENT_STATUSES = new Set([500, 502, 503, 504]); + function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -811,10 +966,12 @@ interface GoogleHealthDataResponse { /** * Send one data request and answer with the response it finally got. * - * Two answers are handled here rather than handed back: + * Three answers are handled here rather than handed back: * - 429: the request waits (see `GOOGLE_HEALTH_RATE_LIMIT`) and is sent * again, up to `maxRetries` times, unless the caller's stop condition * says to end the walk. + * - a server error or no answer at all (see + * `GOOGLE_HEALTH_TRANSIENT_RETRY`): the same, with its own count. * - 401 with a token source: the token expired or was revoked between * the source's last check and this request. The source is asked once for * a fresh token and the request is repeated. A second 401, or a 401 on a @@ -830,6 +987,7 @@ async function googleHealthDataRequest( isExpectedFallback: (status: number) => boolean = () => false, ): Promise> { let rateLimited = 0; + let transient = 0; let refreshed = false; let forceRefresh = false; for (;;) { @@ -837,7 +995,33 @@ async function googleHealthDataRequest( typeof token === "string" ? token : await token({ forceRefresh }); forceRefresh = false; const started = performance.now(); - const res = await send(accessToken); + let res: Response; + try { + res = await send(accessToken); + } catch (err) { + // No answer at all: the request timed out or the connection broke. + // On a walk of a thousand pages one of those is a matter of time, and + // it cost the whole walk. + const retry = + err instanceof SafeFetchError && + (err.kind === "timeout" || err.kind === "network") && + transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && + !stopRequested(); + const delayMs = + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient; + getEvent()?.addExternalCall({ + service: "google-health", + method: label, + duration_ms: Math.round(performance.now() - started), + ...(retry + ? { note: `no_response_retrying_in_ms=${delayMs}` } + : { error: err instanceof Error ? err.message : String(err) }), + }); + if (!retry) throw err; + transient += 1; + await waitMs(delayMs); + continue; + } const json = (await res.json().catch(() => null)) as T | null; const verdict = classifyGoogleHealthResponse(res.status); const apiErrorDetail = @@ -859,6 +1043,17 @@ async function googleHealthDataRequest( !stopRequested(); const willRefresh = res.status === 401 && typeof token !== "string" && !refreshed; + const transientDelayMs = GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) + ? Math.min( + googleHealthRetryDelayMs(res, json) ?? + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient, + GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs, + ) + : 0; + const willRetryTransient = + GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) && + transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries && + !stopRequested(); getEvent()?.addExternalCall({ service: "google-health", @@ -871,14 +1066,16 @@ async function googleHealthDataRequest( ? { note: `rate_limited_retrying_in_ms=${backoffMs}` } : willRefresh ? { note: "token_rejected_retrying_with_fresh_token" } - : { - error: - verdict.classification === "success" - ? undefined - : apiErrorDetail - ? `${verdict.reason} ${apiErrorDetail}` - : verdict.reason, - }), + : willRetryTransient + ? { note: `server_error_retrying_in_ms=${transientDelayMs}` } + : { + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, + }), }); if (willWait) { @@ -886,6 +1083,11 @@ async function googleHealthDataRequest( await waitMs(backoffMs); continue; } + if (willRetryTransient) { + transient += 1; + await waitMs(transientDelayMs); + continue; + } if (willRefresh) { refreshed = true; forceRefresh = true; @@ -947,8 +1149,9 @@ export async function forEachDataPointPage( query: DataPointQuery, onPage: (points: GoogleHealthDataPoint[]) => void | Promise, ): Promise { - const maxPages = query.maxPages ?? 1000; + const maxPages = query.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES; const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE; + const resumable = query.resumable === true; let requestCount = 0; @@ -961,11 +1164,12 @@ export async function forEachDataPointPage( let stopped = false; do { - if (stopRequested()) { + if (stopRequested(resumable)) { stopped = true; break; } const params = new URLSearchParams({ pageSize: String(pageSize) }); + const filters: string[] = []; if (query.start) { const { field, bound } = incrementalFilter( dataType, @@ -973,8 +1177,20 @@ export async function forEachDataPointPage( query.tz, dateStyle, ); - params.set("filter", `${field} >= "${bound}"`); + filters.push(`${field} >= "${bound}"`); } + if (query.edge) { + const { field, bound } = incrementalFilter( + dataType, + query.edge.at, + query.tz, + dateStyle, + ); + filters.push( + `${field} ${query.edge.order === "desc" ? "<=" : ">="} "${bound}"`, + ); + } + if (filters.length > 0) params.set("filter", filters.join(" AND ")); if (pageToken) params.set("pageToken", pageToken); requestCount += 1; @@ -985,7 +1201,7 @@ export async function forEachDataPointPage( // an error next to the 200 that followed it. It is recorded as a note // now; any other 400 still carries its error. const firstRequest = requestCount === 1; - const { json, verdict, apiErrorDetail } = + const { status, json, verdict, apiErrorDetail } = await googleHealthDataRequest( accessToken, `${verb}(page=${pageCount})`, @@ -1003,6 +1219,15 @@ export async function forEachDataPointPage( firstRequest && status === 400, ); + // A rate limit the request could not wait out ends a resumable walk + // inside a backfill slice where it is: the pages before it are written + // and their edge recorded, so this is a pause, not a failure. The slice + // ends with it, since every other walk would meet the same quota. + if (status === 429 && resumable && inSlice()) { + endSlice("rate_limited"); + stopped = true; + break; + } if (verdict.classification !== "success") { throw new GoogleHealthApiError({ verb, @@ -1017,6 +1242,7 @@ export async function forEachDataPointPage( pageToken = json?.nextPageToken ?? null; pageCount += 1; fetched += page.length; + noteSlicePage(); await onPage(page); } while (pageToken && pageCount < maxPages); @@ -1245,7 +1471,7 @@ export async function fetchDailyRollUp( let totalPages = 0; let truncated = false; for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) { - if (stopRequested()) { + if (stopRequested(false)) { truncated = true; break; } @@ -1295,6 +1521,7 @@ export async function fetchDailyRollUp( for (const p of json?.rollupDataPoints ?? []) points.push(p); pageToken = json?.nextPageToken ?? null; pageCount += 1; + noteSlicePage(); } while (pageToken && pageCount < 100); totalPages += pageCount; truncated ||= Boolean(pageToken); diff --git a/src/lib/google-health/mappers.ts b/src/lib/google-health/mappers.ts index d73666dc6..194a782ff 100644 --- a/src/lib/google-health/mappers.ts +++ b/src/lib/google-health/mappers.ts @@ -387,6 +387,21 @@ function resolveMeasuredAt( return fallback; } +/** + * The sample instant of a spot-reading `DataPoint` (`sample` data types), or + * null for any other shape or a point without a parseable time. A backfill + * reads it to record how far a walk has got. + */ +export function googleHealthSampleInstant( + point: GoogleHealthDataPoint, + dataType: GoogleHealthDataType, + tz?: string, +): Date | null { + if (dataType.timeField !== "sample") return null; + const t = readPath(point, `${dataType.key}.sampleTime.physicalTime`); + return typeof t === "string" ? parseLocalInstant(t, tz) : null; +} + /** * Stable anchor for a `DataPoint`'s externalId. A spot reading anchors on its * sample time; a daily summary on its civil date. Combined with a type-specific diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index dc215266d..80004abf5 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -37,6 +37,9 @@ import { import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { googleHealthOutcomeTruncated, + googleHealthSliceExhausted, + googleHealthSlicePagesRead, + noteGoogleHealthDeferred, noteGoogleHealthMapped, noteGoogleHealthOutcomeFailure, noteGoogleHealthWritten, @@ -402,33 +405,75 @@ export async function runWithGoogleHealthSyncCycle( } /** - * Collections a full-history backfill already walked to the end, carried from - * one attempt of the backfill to the next. + * How far a full-history backfill has got, carried from one attempt of the + * backfill to the next. * - * A backfill that ended early (its job budget ran out, a collection failed) - * used to start the next attempt from the first page of the first collection, - * so the dense heart-rate walk, thousands of pages, was paid for again on - * every retry, and an account whose walk never fit into one attempt never - * finished at all. Each collection that completes cleanly is now written to - * `backfillProgress`, and the next attempt skips it and goes on with the - * collections it did not reach. A collection is the unit because Google - * offers no cursor that survives between attempts; its page tokens are not - * documented to outlive the walk that received them. + * Two grains. A collection walked to the end is noted in `done`, and the next + * attempt skips it. A collection read page by page (the spot readings: heart + * rate, weight, glucose, ...) also notes, after every page it writes, the + * edge it has reached: the oldest sample written so far when the walk runs + * newest first, as Google's list does. The next attempt asks Google only for + * the samples at or beyond that edge, so a collection that does not fit into + * one attempt is worked through over several instead of being read from its + * first page every time. + * + * The edge is a sample time, not Google's page token: page tokens are not + * documented to outlive the walk that received them, a sample time is a + * filter Google documents. The one sample on the edge itself is read twice + * and overwrites its own row. + * + * Before the edge existed only `done` was kept, and a heart-rate history of + * years that one attempt could not finish (its budget, a rate limit, one + * failed page among thousands) started again from the newest page on every + * attempt and never reached the end (#1220). */ +export interface GoogleHealthBackfillCursor { + /** The edge: the sample instant (ISO 8601) the walk has written up to. */ + at: string; + /** The order the walk returned samples in: newest first, or oldest first. */ + order: "desc" | "asc"; + /** Pages this collection has read across every attempt so far. */ + pages: number; +} + +/** What one collection came to in one backfill attempt. */ +export interface GoogleHealthBackfillCollectionReport { + key: string; + /** + * `done`: walked to the end. `stopped`: ended short on a budget, a page + * ceiling or a rate limit, with nothing failed. `deferred`: not started, + * left for the next slice. `failed`: a request or a write failed, or the + * collection answered 403. + */ + status: "done" | "stopped" | "deferred" | "failed"; + pages: number; + durationMs: number; + /** The edge before and after this attempt, for a resumable collection. */ + edgeFrom?: string | null; + edgeTo?: string | null; +} + interface BackfillResumeTracker { userId: string; done: Set; + cursors: Map; /** Collections this attempt skipped because an earlier one finished them. */ skipped: string[]; + /** Collections whose edge this attempt moved, with the edge they started from. */ + advanced: Map; + collections: GoogleHealthBackfillCollectionReport[]; } const backfillResumeStorage = new AsyncLocalStorage(); +function progressRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + /** Read the `backfillProgress` column into the set of finished collections. */ export function readGoogleHealthBackfillDone(value: unknown): Set { - const done = - value && typeof value === "object" && !Array.isArray(value) - ? (value as { done?: unknown }).done - : undefined; + const done = progressRecord(value).done; return new Set( Array.isArray(done) ? done.filter( @@ -438,20 +483,188 @@ export function readGoogleHealthBackfillDone(value: unknown): Set { ); } +/** Read the `backfillProgress` column into the per-collection edges. */ +export function readGoogleHealthBackfillCursors( + value: unknown, +): Map { + const out = new Map(); + const cursors = progressRecord(progressRecord(value).cursors); + for (const [key, raw] of Object.entries(cursors)) { + if (key.length > 64) continue; + const c = progressRecord(raw); + const at = typeof c.at === "string" ? c.at : null; + if (!at || Number.isNaN(Date.parse(at))) continue; + out.set(key, { + at: new Date(at).toISOString(), + order: c.order === "asc" ? "asc" : "desc", + pages: + typeof c.pages === "number" && Number.isFinite(c.pages) && c.pages > 0 + ? Math.trunc(c.pages) + : 0, + }); + } + return out; +} + +/** + * Write the tracker's progress with one change applied, and apply it to the + * tracker only once the write has landed: progress that is not on disk is + * progress the next attempt cannot see, so it must not count in this one. + */ +async function persistBackfillProgress( + tracker: BackfillResumeTracker, + next: { + done: Set; + cursors: Map; + }, +): Promise { + await prisma.googleHealthConnection.update({ + where: { userId: tracker.userId }, + data: { + backfillProgress: { + done: [...next.done], + cursors: Object.fromEntries( + [...next.cursors].map(([key, c]) => [ + key, + { at: c.at, order: c.order, pages: c.pages }, + ]), + ), + }, + }, + }); + // `next` may be the tracker's own collections: copy before clearing. + const done = [...next.done]; + const cursors = [...next.cursors]; + tracker.done.clear(); + for (const key of done) tracker.done.add(key); + tracker.cursors.clear(); + for (const [key, c] of cursors) tracker.cursors.set(key, c); +} + +/** What one backfill attempt did, as `runWithGoogleHealthBackfillResume` reports it. */ +export interface GoogleHealthBackfillAttempt { + result: T; + skipped: string[]; + /** Collections whose edge moved, each with the edge it started from. */ + advanced: Map; + collections: GoogleHealthBackfillCollectionReport[]; +} + /** * Run a full-history backfill attempt that resumes: collections in `done` are - * skipped, and each collection finished cleanly during this attempt is added - * to it and persisted at once, so an attempt that dies part-way keeps what it - * finished. + * skipped, a collection with an edge in `cursors` starts from it, and each + * collection finished or page written during this attempt is persisted at + * once, so an attempt that dies part-way keeps what it did. */ export async function runWithGoogleHealthBackfillResume( userId: string, done: Set, fn: () => Promise, -): Promise<{ result: T; skipped: string[] }> { - const tracker: BackfillResumeTracker = { userId, done, skipped: [] }; + cursors: Map = new Map(), +): Promise> { + const tracker: BackfillResumeTracker = { + userId, + done, + cursors, + skipped: [], + advanced: new Map(), + collections: [], + }; const result = await backfillResumeStorage.run(tracker, fn); - return { result, skipped: tracker.skipped }; + return { + result, + skipped: tracker.skipped, + advanced: tracker.advanced, + collections: tracker.collections, + }; +} + +/** + * Where a resumable collection's walk starts in this backfill attempt: the + * edge an earlier attempt recorded (if any) and the pages it has already + * read. Null outside a backfill, where every walk reads its whole window. + */ +export function googleHealthBackfillCursor(key: GoogleHealthCollectionKey): { + edge?: { at: Date; order: "desc" | "asc" }; + pagesSoFar: number; +} | null { + const resume = backfillResumeStorage.getStore(); + if (!resume) return null; + const cursor = resume.cursors.get(key); + return cursor + ? { + edge: { at: new Date(cursor.at), order: cursor.order }, + pagesSoFar: cursor.pages, + } + : { pagesSoFar: 0 }; +} + +/** + * Record that a resumable collection wrote one more page, holding samples + * from `first` to `last` in the order Google returned them. Moves the + * collection's edge to the far end of the page and persists it. Outside a + * backfill this does nothing. + * + * The order is read off the first page whose two ends differ and kept for + * the rest of the backfill; Google returns its lists newest first, which is + * also what a page that cannot tell is taken to mean. + */ +export async function recordGoogleHealthBackfillPage( + key: GoogleHealthCollectionKey, + page: { first: Date | null; last: Date | null }, +): Promise { + const resume = backfillResumeStorage.getStore(); + if (!resume) return; + const current = resume.cursors.get(key); + const order: "desc" | "asc" = + current?.order ?? + (page.first && page.last && page.first.getTime() < page.last.getTime() + ? "asc" + : "desc"); + const ends = [page.first, page.last].filter((d): d is Date => d !== null); + const far = + ends.length === 0 + ? null + : new Date( + order === "desc" + ? Math.min(...ends.map((d) => d.getTime())) + : Math.max(...ends.map((d) => d.getTime())), + ); + const currentAt = current ? Date.parse(current.at) : null; + const moved = + far !== null && + (currentAt === null || + (order === "desc" + ? far.getTime() < currentAt + : far.getTime() > currentAt)); + if (!current && !moved) return; + const nextCursor: GoogleHealthBackfillCursor = { + at: moved ? far.toISOString() : current!.at, + order, + pages: (current?.pages ?? 0) + 1, + }; + const cursors = new Map(resume.cursors); + cursors.set(key, nextCursor); + try { + await persistBackfillProgress(resume, { done: resume.done, cursors }); + if (moved && !resume.advanced.has(key)) { + resume.advanced.set(key, current?.at ?? null); + } + } catch (err) { + // The page's rows are written; only the note of how far the walk got is + // missing, so the next attempt reads this page again. Not a failure. + getEvent()?.addWarning( + `google-health: backfill progress write failed for ${key}: ${err}`, + ); + annotate({ + meta: { "googleHealth.backfill.progressWriteFailed": key }, + }); + } +} + +/** Hard failures the cycle in scope has recorded so far. */ +export function googleHealthHardFailureCount(): number { + return hardFailStorage.getStore()?.failures.length ?? 0; } /** @@ -459,10 +672,12 @@ export async function runWithGoogleHealthBackfillResume( * one in scope. Outside a backfill this is just `fn()`. * * Inside one, a collection already walked to the end by an earlier attempt is - * skipped. Otherwise it runs, and it counts as finished only when nothing - * went wrong while it ran: no hard failure on the cycle's ledger, no 403 - * soft-skip, and no walk that ended short (its page ceiling, or the job's - * budget asking it to stop). Anything less is walked again next attempt. + * skipped, and so is one the slice has no budget left to start: it is left + * for the next slice, and the cycle reports itself incomplete. Otherwise it + * runs, and it counts as finished only when nothing went wrong while it ran: + * no hard failure on the cycle's ledger, no 403 soft-skip, and no walk that + * ended short (its page ceiling, or a budget asking it to stop). Anything + * less is walked again next attempt, from its edge when it has one. */ export async function runGoogleHealthCollection( key: GoogleHealthCollectionKey, @@ -474,15 +689,29 @@ export async function runGoogleHealthCollection( resume.skipped.push(key); return 0; } + if (googleHealthSliceExhausted()) { + noteGoogleHealthDeferred(); + resume.collections.push({ + key, + status: "deferred", + pages: 0, + durationMs: 0, + }); + return 0; + } + const startedAt = performance.now(); + const pagesBefore = googleHealthSlicePagesRead(); + const edgeFrom = resume.cursors.get(key)?.at ?? null; const hardBefore = hardFailStorage.getStore()?.failures.length ?? 0; const softBefore = softSkipStorage.getStore()?.count ?? 0; const truncatedBefore = googleHealthOutcomeTruncated(); const imported = await fn(); - const clean = - (hardFailStorage.getStore()?.failures.length ?? 0) === hardBefore && - (softSkipStorage.getStore()?.count ?? 0) === softBefore && - !truncatedBefore && - !googleHealthOutcomeTruncated(); + const failedHard = + (hardFailStorage.getStore()?.failures.length ?? 0) !== hardBefore || + (softSkipStorage.getStore()?.count ?? 0) !== softBefore; + const truncated = truncatedBefore || googleHealthOutcomeTruncated(); + const clean = !failedHard && !truncated; + let finished = false; if (clean) { // The collection counts as finished only once the note says so. The job // reads the size of `done` as this attempt's progress and sends another @@ -490,11 +719,13 @@ export async function runGoogleHealthCollection( // progress nobody can see next attempt, and a chain of slices could keep // "progressing" over the same collection forever. try { - await prisma.googleHealthConnection.update({ - where: { userId: resume.userId }, - data: { backfillProgress: { done: [...resume.done, key] } }, + const cursors = new Map(resume.cursors); + cursors.delete(key); + await persistBackfillProgress(resume, { + done: new Set([...resume.done, key]), + cursors, }); - resume.done.add(key); + finished = true; } catch (err) { // Not finished, then: the collection is walked again next attempt. It // must not fail a collection whose rows are written. @@ -506,6 +737,14 @@ export async function runGoogleHealthCollection( }); } } + const edgeTo = finished ? null : (resume.cursors.get(key)?.at ?? null); + resume.collections.push({ + key, + status: finished ? "done" : failedHard ? "failed" : "stopped", + pages: googleHealthSlicePagesRead() - pagesBefore, + durationMs: Math.round(performance.now() - startedAt), + ...(edgeFrom !== null || edgeTo !== null ? { edgeFrom, edgeTo } : {}), + }); return imported; } diff --git a/src/lib/google-health/sync-lock.ts b/src/lib/google-health/sync-lock.ts index db8ca51c8..ddb16523d 100644 --- a/src/lib/google-health/sync-lock.ts +++ b/src/lib/google-health/sync-lock.ts @@ -16,7 +16,9 @@ * the hourly poll skips the account for this tick, the manual trigger answers * that a sync is already running, and the backfill and the repair wait a * bounded time for a short run to finish before handing their job back to - * pg-boss to retry. + * pg-boss to retry. The hourly poll waits too, for about one backfill slice: + * a backfill lets go of the account between its slices, so the hourly sync + * gets its turn instead of being skipped for as long as the import runs. */ import { withJobLock, type GuardedRun } from "@/lib/jobs/job-lock"; @@ -32,6 +34,16 @@ export function googleHealthSyncLockKey(userId: string): string { */ export const GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS = 10 * 60 * 1000; +/** + * How long the hourly poll waits for an account another run holds. A backfill + * slice walks for five minutes and then lets go + * (`GOOGLE_HEALTH_BACKFILL_SLICE_MS`); the page in flight, a rate-limit wait + * and the slice's own bookkeeping can add a minute or two, and the next slice + * starts a minute after this one ends. Eight minutes covers that, and leaves + * the hourly job well inside its fifteen-minute expiry. + */ +export const GOOGLE_HEALTH_POLL_LOCK_WAIT_MS = 8 * 60 * 1000; + /** * Run `run` while holding the account's Google Health lock. Resolves * `{ ran: false }` without calling `run` when another run holds it and does diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index a86dfb59e..2bcad8002 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -27,12 +27,14 @@ */ import { GOOGLE_HEALTH_DATA_TYPES, + GOOGLE_HEALTH_DEFAULT_MAX_PAGES, GOOGLE_HEALTH_DENSE_MAX_PAGES, type GoogleHealthDataType, type GoogleHealthMappedMeasurement, type GoogleHealthTokenSource, fetchDataPoints, forEachDataPointPage, + googleHealthSampleInstant, mapBloodGlucose, mapBodyFat, mapCoreBodyTemperature, @@ -44,11 +46,15 @@ import { mapRestingHeartRate, mapWeight, mapWristTemperature, + noteGoogleHealthDeferred, } from "./client"; import { getValidToken, + googleHealthBackfillCursor, + googleHealthHardFailureCount, googleHealthTokenSource, handleCollectionFetchError, + recordGoogleHealthBackfillPage, runGoogleHealthCollection, upsertGoogleHealthMeasurements, type GoogleHealthMeasurementUpsert, @@ -108,13 +114,6 @@ const METRIC_RESOURCES: MetricResource[] = [ map: mapRespiratoryRate, verb: "fetchRespiratoryRate", }, - { - dataType: GOOGLE_HEALTH_DATA_TYPES.heartRate, - map: mapHeartRate, - verb: "fetchHeartRate", - maxPages: GOOGLE_HEALTH_DENSE_MAX_PAGES, - intraday: true, - }, { dataType: GOOGLE_HEALTH_DATA_TYPES.bloodGlucose, map: mapBloodGlucose, @@ -133,8 +132,34 @@ const METRIC_RESOURCES: MetricResource[] = [ map: mapWristTemperature, verb: "fetchSleepTemperature", }, + // Last: the dense stream. A backfill finishes every light collection in + // its first slice and then works through this one slice by slice; ahead + // of them it held them back until the whole stream was in. + { + dataType: GOOGLE_HEALTH_DATA_TYPES.heartRate, + map: mapHeartRate, + verb: "fetchHeartRate", + maxPages: GOOGLE_HEALTH_DENSE_MAX_PAGES, + intraday: true, + }, ]; +/** + * Whether a metric is walked resumably by a backfill: written page by page, + * and a spot reading whose sample time can mark how far the walk got. + */ +function isResumable(resource: MetricResource): boolean { + return resource.dataType.timeField === "sample"; +} + +/** + * The page ceilings of every resumable collection, summed: the most pages a + * backfill can ever spend on them across all of its attempts. + */ +export const GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING = METRIC_RESOURCES.filter( + isResumable, +).reduce((sum, r) => sum + (r.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES), 0); + export async function syncUserMetrics( userId: string, opts: GoogleHealthResourceSyncOptions = {}, @@ -245,6 +270,27 @@ async function syncMetricCollection( opts: GoogleHealthResourceSyncOptions, ): Promise { let imported = 0; + // Inside a backfill a spot-reading collection keeps its place: it starts + // from the edge an earlier attempt reached and records its new edge after + // every page it writes (see `GoogleHealthBackfillCursor`). Its page ceiling + // then counts across attempts, so a cursor that never ends is still bounded. + const cursor = isResumable(resource) + ? googleHealthBackfillCursor(resource.verb) + : null; + const maxPages = + (resource.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES) - + (cursor?.pagesSoFar ?? 0); + if (cursor && maxPages <= 0) { + getEvent()?.addWarning( + `google-health ${resource.verb}: backfill reached its page ceiling of ${resource.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES} pages for ${userId}`, + ); + noteGoogleHealthDeferred(); + return 0; + } + // Once one page's write fails, the edge stays where it is for the rest of + // the walk: moving it past the failed page would leave that page's rows + // behind it for good. + let edgeHeld = false; try { await forEachDataPointPage( resource.dataType, @@ -254,7 +300,8 @@ async function syncMetricCollection( start: resource.intraday ? (opts.intradayStart ?? opts.start) : opts.start, - maxPages: resource.maxPages, + maxPages, + ...(cursor ? { resumable: true, edge: cursor.edge } : {}), }, async (points) => { const readings: GoogleHealthMeasurementUpsert[] = []; @@ -269,11 +316,23 @@ async function syncMetricCollection( }); } } + const failuresBefore = googleHealthHardFailureCount(); imported += ( await upsertGoogleHealthMeasurements(userId, readings, { deferRollup: opts.deferRollup, }) ).imported; + if (!cursor || points.length === 0) return; + if (googleHealthHardFailureCount() !== failuresBefore) edgeHeld = true; + if (edgeHeld) return; + await recordGoogleHealthBackfillPage(resource.verb, { + first: googleHealthSampleInstant(points[0]!, resource.dataType, tz), + last: googleHealthSampleInstant( + points[points.length - 1]!, + resource.dataType, + tz, + ), + }); }, ); } catch (err) { diff --git a/src/lib/jobs/__tests__/google-health-backfill.test.ts b/src/lib/jobs/__tests__/google-health-backfill.test.ts index 4e5e5a269..796893945 100644 --- a/src/lib/jobs/__tests__/google-health-backfill.test.ts +++ b/src/lib/jobs/__tests__/google-health-backfill.test.ts @@ -31,7 +31,13 @@ const { bossSend: vi.fn(), // Collections the mocked walk "finishes" in this attempt, added to the // resume set the way `runGoogleHealthCollection` adds them. - resumeState: { finishes: [] as string[], doneBefore: [] as string[] }, + resumeState: { + finishes: [] as string[], + doneBefore: [] as string[], + /** Collections whose resume edge the mocked walk moves. */ + advances: [] as string[], + collections: [] as Array<{ key: string; status: string }>, + }, })); vi.mock("@/lib/db", () => ({ prisma: prismaMock })); @@ -54,6 +60,7 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { vi.mock("@/lib/google-health/sync-core", () => ({ GOOGLE_HEALTH_INTEGRATION_KEY: "google-health", readGoogleHealthBackfillDone: () => new Set(resumeState.doneBefore), + readGoogleHealthBackfillCursors: () => new Map(), runWithGoogleHealthBackfillResume: async ( _userId: string, done: Set, @@ -61,7 +68,12 @@ vi.mock("@/lib/google-health/sync-core", () => ({ ) => { const result = await fn(); for (const key of resumeState.finishes) done.add(key); - return { result, skipped: [] }; + return { + result, + skipped: [], + advanced: new Map(resumeState.advances.map((key) => [key, null])), + collections: resumeState.collections, + }; }, })); vi.mock("@/lib/google-health/sync", () => ({ @@ -71,8 +83,11 @@ vi.mock("@/lib/google-health/sync", () => ({ import { GOOGLE_HEALTH_BACKFILL_MAX_SLICE, + GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS, runGoogleHealthBackfillForUser, } from "../google-health-backfill"; +import { GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING } from "@/lib/google-health/sync-metrics"; +import { INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS } from "@/lib/jobs/integration-backfill-admission"; import { GOOGLE_HEALTH_DATA_TYPES, forEachDataPointPage, @@ -87,6 +102,8 @@ beforeEach(() => { bossSend.mockResolvedValue("job-next"); resumeState.finishes = []; resumeState.doneBefore = []; + resumeState.advances = []; + resumeState.collections = []; }); describe("runGoogleHealthBackfillForUser — verdict-gated marker", () => { @@ -261,8 +278,71 @@ describe("runGoogleHealthBackfillForUser — slices", () => { expect(bossSend).not.toHaveBeenCalled(); }); - it("caps the chain at the number of collections plus two", () => { - expect(GOOGLE_HEALTH_BACKFILL_MAX_SLICE).toBe(17 + 2); + it("derives the chain's ceiling from the work a backfill can do", () => { + // Every collection once, plus every page the resumable collections may + // ever read (heart rate's 10 000 and four others' 1 000), plus slack. + expect(GOOGLE_HEALTH_BACKFILL_MAX_SLICE).toBe( + 17 + GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING + 2, + ); + expect(GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING).toBe(10_000 + 4 * 1000); + }); + + it("continues a slice that only moved a collection's edge", async () => { + resumeState.advances = ["fetchHeartRate"]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 900, failed: true }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 7), + ).resolves.toEqual({ imported: 900, continued: true }); + expect(bossSend).toHaveBeenCalledTimes(1); + expect(bossSend.mock.calls[0]![1]).toMatchObject({ + data: { userId: "u1", slice: 8 }, + }); + expect(bossSend.mock.calls[0]![2]).toMatchObject({ + startAfter: INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + }); + }); + + it("waits longer before the next slice when this one ended on a rate limit", async () => { + resumeState.advances = ["fetchHeartRate"]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 10, failed: true }); + // The slice ended the way a resumable walk ends it when Google keeps + // answering 429 past every wait. + const client = await import("@/lib/google-health/client"); + const spy = vi + .spyOn(client, "googleHealthSliceBudget") + .mockImplementation(() => ({ + ms: 300_000, + deadline: null, + maxPages: 500, + pages: 3, + stopReason: "rate_limited", + now: Date.now, + })); + try { + await runGoogleHealthBackfillForUser("u1"); + } finally { + spy.mockRestore(); + } + expect(bossSend.mock.calls[0]![2]).toMatchObject({ + startAfter: GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS, + }); + }); + + it("names where it is stuck when a slice moved nothing", async () => { + resumeState.collections = [ + { key: "fetchWeight", status: "done" }, + { key: "fetchHeartRate", status: "failed" }, + { key: "fetchBloodGlucose", status: "deferred" }, + ]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 0, failed: true }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 3), + ).rejects.toThrow( + /marker not stamped \(no progress in slice 3; fetchHeartRate:failed,fetchBloodGlucose:deferred\)/, + ); + expect(bossSend).not.toHaveBeenCalled(); }); it("stamps the marker on the slice that finishes the history", async () => { diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index 4f3a00294..05a3a27bd 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -21,6 +21,7 @@ import { integrationBackfillSourceOptions, } from "@/lib/jobs/integration-backfill-admission"; import { + readGoogleHealthBackfillCursors, readGoogleHealthBackfillDone, runWithGoogleHealthBackfillResume, } from "@/lib/google-health/sync-core"; @@ -29,7 +30,12 @@ import { syncUserGoogleHealth, } from "@/lib/google-health/sync"; import { GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS } from "@/lib/google-health/sync-lock"; -import { runWithGoogleHealthStop } from "@/lib/google-health/client"; +import { + googleHealthSliceBudget, + runWithGoogleHealthStop, +} from "@/lib/google-health/client"; +import { GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING } from "@/lib/google-health/sync-metrics"; +import { reportJobProgress } from "@/lib/jobs/job-observer"; import { GOOGLE_HEALTH_COLLECTION_KEYS } from "@/lib/google-health/collection-keys"; export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill"; @@ -42,15 +48,52 @@ export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill"; export const GOOGLE_HEALTH_BACKFILL_CONCURRENCY = 1; /** - * The highest slice a backfill chain may reach. Every continued slice has - * finished at least one more collection than the one before it, so a healthy - * chain is at most one slice per collection long; the two extra slices are - * slack for the first one and the one that stamps the marker. A slice past - * this is a chain that counts progress it does not make, and it fails rather - * than queueing another. + * One slice of a backfill: how long it walks and how many pages it reads + * before it lets go of the account and hands the rest to the next slice. + * + * Far inside the admission job's expiry (sixteen hours) on purpose. A slice + * holds the account's sync lock while it walks, and the hourly sync of that + * account waits for the lock (`GOOGLE_HEALTH_POLL_LOCK_WAIT_MS`), so a slice + * has to be short enough for the hourly sync to get its turn: before slices, + * one attempt held the lock for as long as the import ran and every hourly + * sync of the account in that time was skipped. The page budget keeps a + * slice on a fast link from spending a whole quota window in one go. + */ +export const GOOGLE_HEALTH_BACKFILL_SLICE_MS = 5 * 60 * 1000; +export const GOOGLE_HEALTH_BACKFILL_SLICE_PAGES = 500; + +/** + * The pause before the next slice when this one ended on a rate limit it + * could not wait out: long enough for Google's per-minute window to refill + * several times over, so the next slice does not open on the same 429. + */ +export const GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS = 5 * 60; + +/** + * The highest slice a backfill chain may reach, derived from the work a + * backfill can do rather than guessed. + * + * A slice is continued only when it made progress that is on disk: it + * finished a collection, or it moved a resumable collection's edge after + * writing at least one page. Collections finish once each, and a resumable + * collection's pages are capped across all attempts by its page ceiling + * (`GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING`, the sum of them). So no chain of + * progressing slices is longer than the collections plus those pages; the two + * extra are slack for the first slice and the one that stamps the marker. In + * practice a chain is a handful of slices per year of dense history. A slice + * past this is counting progress it does not make, and fails rather than + * queueing another. */ export const GOOGLE_HEALTH_BACKFILL_MAX_SLICE = - GOOGLE_HEALTH_COLLECTION_KEYS.length + 2; + GOOGLE_HEALTH_COLLECTION_KEYS.length + + GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING + + 2; + +/** Test seam: a slice's budget, when not the production one. */ +export interface GoogleHealthBackfillSliceOptions { + sliceMs?: number; + slicePages?: number; +} export interface GoogleHealthBackfillPayload { userId: string; @@ -58,42 +101,40 @@ export interface GoogleHealthBackfillPayload { } /** - * Per-user backfill handler. Runs a full-history sync for one account and - * stamps `backfillCompletedAt` ONLY on a clean run, so the discovery query - * drops it. Idempotent: the per-resource upserts are key-stable, so a re-run - * (e.g. a reboot mid-walk) overwrites rather than duplicating. Mirrors - * `runFitbitBackfillForUser`. + * Per-user backfill handler. Runs one slice of a full-history sync for one + * account and stamps `backfillCompletedAt` ONLY on a clean run, so the + * discovery query drops it. Idempotent: the per-resource upserts are + * key-stable, so a re-run (e.g. a reboot mid-walk) overwrites rather than + * duplicating. Mirrors `runFitbitBackfillForUser`. * * Verdict-gated: a partial hard failure (one collection 400ing, a write - * failing) THROWS so pg-boss retries the job — stamping the marker over a - * partial walk would freeze the gap in forever. A connection parked at - * `error_reauth` is NOT clean either, but throwing against a dead grant would - * just burn the retry budget on the same no-op: return WITHOUT stamping — the - * boot discovery re-enqueues the account on the next boot, and the stamp lands - * once the user reconnects and a clean walk completes. + * failing) is not complete, and stamping the marker over a partial walk would + * freeze the gap in forever. A connection parked at `error_reauth` is NOT + * clean either, but throwing against a dead grant would just burn the retry + * budget on the same no-op: return WITHOUT stamping — the boot discovery + * re-enqueues the account on the next boot, and the stamp lands once the user + * reconnects and a clean walk completes. * - * `shouldStop` is the admission job's budget (`jobBudget`). A per-minute - * heart-rate history runs to thousands of pages, and the walk asks it before - * every page: once it says stop, the walk ends where it is, the cycle reports - * itself truncated, and this throws like any other incomplete run, without - * the stamp. The rows already written stay. + * Resumable at two grains (`GoogleHealthBackfillCursor`): every collection the + * walk finishes cleanly is noted in `backfillProgress.done` and skipped from + * then on, and every spot-reading collection notes the edge it has written up + * to after each page, so the next attempt starts there instead of at the + * newest page. The note is cleared once the stamp lands, and on every + * reconnect. * - * Resumable: every collection the walk finishes cleanly is noted in - * `backfillProgress`, and the next attempt (the pg-boss retry, or the next - * boot's discovery) skips those and walks only what is left - * (`runWithGoogleHealthBackfillResume`). The note is cleared once the stamp - * lands, and on every reconnect. + * Runs in slices. A slice walks until its own budget is spent + * (`GOOGLE_HEALTH_BACKFILL_SLICE_MS` / `_PAGES`), Google throttles it past + * what a wait covers, or the job's budget (`shouldStop`) runs out, and then + * lets go of the account. A slice that made progress on disk (a collection + * finished, an edge moved) is not a failure: it sends the next slice + * (`slice + 1`) and returns `continued`, without the error line or the + * queue's failure count. Only a slice that moved nothing throws, with what + * each collection came to in the message, because that one is stuck: the + * pg-boss retry and, after it, the next boot take it from there. The chain is + * capped at `GOOGLE_HEALTH_BACKFILL_MAX_SLICE`. * - * Runs in slices: an attempt that ends before the whole history is in but - * finished at least one more collection than it started with made progress. - * It is not a failure: it sends the next slice (`slice + 1`) and returns - * `continued`, without the error line or the queue's failure count. Only an - * attempt that finished nothing new throws, because that one is stuck: the - * pg-boss retry and, after it, the next boot take it from there. Progress is - * counted only once it is persisted (`runGoogleHealthCollection` adds a - * collection to `done` after its note is written), and the chain is capped at - * `GOOGLE_HEALTH_BACKFILL_MAX_SLICE`: a slice past it throws instead of - * running or sending another. + * What the slice did is annotated on the run's event as + * `googleHealth.backfill.slice` (see `docs/integrations/google-health.md`). * * Never beside another run of the same account: the sync takes the account's * lock, waiting a bounded time for an hourly or manual run to finish. If the @@ -104,6 +145,7 @@ export async function runGoogleHealthBackfillForUser( userId: string, shouldStop: () => boolean = () => false, slice: number = 0, + sliceOptions: GoogleHealthBackfillSliceOptions = {}, ): Promise<{ imported: number; continued?: boolean }> { if (slice > GOOGLE_HEALTH_BACKFILL_MAX_SLICE) { annotate({ @@ -142,22 +184,56 @@ export async function runGoogleHealthBackfillForUser( return { imported: 0 }; } const done = readGoogleHealthBackfillDone(connection?.backfillProgress); + const cursors = readGoogleHealthBackfillCursors(connection?.backfillProgress); const doneBefore = done.size; + const budget = googleHealthSliceBudget( + sliceOptions.sliceMs ?? GOOGLE_HEALTH_BACKFILL_SLICE_MS, + sliceOptions.slicePages ?? GOOGLE_HEALTH_BACKFILL_SLICE_PAGES, + ); + const startedAt = Date.now(); const { result: { imported, failed, busy }, skipped, - } = await runWithGoogleHealthBackfillResume(userId, done, () => - runWithGoogleHealthStop(shouldStop, () => - syncUserGoogleHealth(userId, { - fullSync: true, - waitForLockMs: GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS, - }), - ), + advanced, + collections, + } = await runWithGoogleHealthBackfillResume( + userId, + done, + () => + runWithGoogleHealthStop( + shouldStop, + () => + syncUserGoogleHealth(userId, { + fullSync: true, + waitForLockMs: GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS, + }), + budget, + ), + cursors, ); - if (skipped.length > 0) { - annotate({ meta: { "googleHealth.backfill.resumedPast": skipped } }); - } + + const finished = done.size - doneBefore; + const sliceReport = { + slice, + elapsedMs: Date.now() - startedAt, + pages: budget.pages, + stopReason: budget.stopReason, + imported, + collectionsDone: done.size, + collectionsTotal: GOOGLE_HEALTH_COLLECTION_KEYS.length, + collectionsFinished: finished, + resumedPast: skipped.length, + collections, + // Where each unfinished resumable collection has got to, after this slice. + edges: Object.fromEntries([...cursors].map(([key, c]) => [key, c.at])), + }; + annotate({ meta: { "googleHealth.backfill.slice": sliceReport } }); + reportJobProgress({ + google_health_backfill_slice: slice, + google_health_backfill_pages: budget.pages, + google_health_backfill_collections_done: done.size, + }); if (busy) { throw new Error( @@ -166,10 +242,10 @@ export async function runGoogleHealthBackfillForUser( } if (failed) { - // `done` is the resume set this attempt added to as it finished - // collections; a larger set than it started with is progress. - const finished = done.size - doneBefore; - if (finished > 0 && slice + 1 <= GOOGLE_HEALTH_BACKFILL_MAX_SLICE) { + // Progress is what this slice put on disk: collections finished, and + // edges moved. Either is enough to go on; neither means stuck. + const progressed = finished > 0 || advanced.size > 0; + if (progressed && slice + 1 <= GOOGLE_HEALTH_BACKFILL_MAX_SLICE) { const boss = getGlobalBoss(); const sent = boss ? await enqueueIntegrationBackfillAdmission( @@ -182,7 +258,9 @@ export async function runGoogleHealthBackfillForUser( slice: slice + 1, }, }, - INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + budget.stopReason === "rate_limited" + ? GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS + : INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, ) : null; if (sent) { @@ -192,8 +270,10 @@ export async function runGoogleHealthBackfillForUser( details: { imported, slice, + stopReason: budget.stopReason, collectionsFinished: finished, collectionsDone: done.size, + edgesMoved: [...advanced.keys()], }, }, }); @@ -202,12 +282,23 @@ export async function runGoogleHealthBackfillForUser( // No follow-up could be queued; fall through to the retry path so the // work is not dropped. } - // No collection finished in this attempt: the backfill is stuck, not - // slow. Surface through the pg-boss retry path (retryLimit 3, backoff). - // If the failure parked the connection at error_reauth meanwhile, the - // next attempt takes the reauth return above instead of failing again. + // Nothing moved in this slice: the backfill is stuck, not slow. Surface + // through the pg-boss retry path (retryLimit 3, backoff) with what each + // collection came to, so the log line says where it is stuck. If the + // failure parked the connection at error_reauth meanwhile, the next + // attempt takes the reauth return above instead of failing again. + annotate({ + action: { + name: "google_health.backfill.stuck", + details: { slice, stopReason: budget.stopReason }, + }, + }); + const where = collections + .filter((c) => c.status !== "done") + .map((c) => `${c.key}:${c.status}`) + .join(","); throw new Error( - `google-health backfill incomplete for user ${userId} — marker not stamped`, + `google-health backfill incomplete for user ${userId} — marker not stamped (no progress in slice ${slice}${budget.stopReason ? `, stopped on ${budget.stopReason}` : ""}${where ? `; ${where}` : ""})`, ); } @@ -219,7 +310,7 @@ export async function runGoogleHealthBackfillForUser( annotate({ action: { name: "google_health.backfill.complete", - details: { imported }, + details: { imported, slice }, }, }); return { imported }; diff --git a/src/lib/jobs/reminder/google-health-sync.ts b/src/lib/jobs/reminder/google-health-sync.ts index f58329bc0..7ac2e7d0f 100644 --- a/src/lib/jobs/reminder/google-health-sync.ts +++ b/src/lib/jobs/reminder/google-health-sync.ts @@ -15,6 +15,7 @@ import { isGoogleHealthParked, syncUserGoogleHealth, } from "@/lib/google-health/sync"; +import { GOOGLE_HEALTH_POLL_LOCK_WAIT_MS } from "@/lib/google-health/sync-lock"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { cleanupExpiredGoogleHealthOAuthStates } from "@/lib/jobs/google-health-oauth-state-cleanup"; import { @@ -69,9 +70,15 @@ export async function handleGoogleHealthSync( } try { - const result = await syncUserGoogleHealth(userId); + // A backfill holds the account for one slice at a time, so + // the tick waits for the slice to end rather than skipping the + // account for as long as the whole import runs. + const result = await syncUserGoogleHealth(userId, { + waitForLockMs: GOOGLE_HEALTH_POLL_LOCK_WAIT_MS, + }); // Another run of this account (its backfill, a manual sync) - // holds the account; this tick leaves it to that run. + // still held the account after the wait; this tick leaves it + // to that run. if (result.busy) return { status: "skipped", imported: 0 }; const parked = result.failed && diff --git a/tests/integration/google-health-backfill-rate-limit.test.ts b/tests/integration/google-health-backfill-rate-limit.test.ts index 2cf7bd9a1..3ddb049fa 100644 --- a/tests/integration/google-health-backfill-rate-limit.test.ts +++ b/tests/integration/google-health-backfill-rate-limit.test.ts @@ -57,6 +57,8 @@ const fake = vi.hoisted(() => ({ throttledPages: new Set(), /** Revoke the current access token when this heart-rate page is asked. */ revokeAtPage: -1, + /** Answer every heart-rate request with a 500. */ + failHeartRate: false, /** Hold the request for this heart-rate page until `release` is called. */ holdAtPage: -1, release: null as null | (() => void), @@ -138,13 +140,24 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { { "retry-after": "0" }, ); } + if (fake.failHeartRate) { + return json({ error: { code: 500, status: "INTERNAL" } }, 500); + } if (page === fake.holdAtPage) { fake.holdAtPage = -1; await fake.held; } - const end = Math.min(fake.heartRatePoints, (page + 1) * 10); + // Newest first, one point a minute; a resumed walk's `<=` edge + // filter starts the list at the edge. + const edge = /physical_time <= "([^"]+)"/.exec( + u.searchParams.get("filter") ?? "", + )?.[1]; + const first = edge + ? Math.ceil((fake.now - Date.parse(edge)) / 60_000) + : 0; + const end = Math.min(fake.heartRatePoints, first + (page + 1) * 10); const dataPoints = []; - for (let g = page * 10; g < end; g++) { + for (let g = first + page * 10; g < end; g++) { dataPoints.push({ name: `users/me/dataTypes/heart-rate/dataPoints/${1_000_000 + g}`, heartRate: { @@ -221,6 +234,7 @@ beforeEach(async () => { fake.throttledPages = new Set(); fake.revokeAtPage = -1; fake.holdAtPage = -1; + fake.failHeartRate = false; fake.release = null; fake.held = null; fake.requestsByPath = new Map(); @@ -398,8 +412,19 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () ); expect(done).not.toContain("fetchHeartRate"); + // And the heart-rate stream keeps the edge it reached: the oldest of + // the thirty samples it wrote. + expect( + (afterFirst?.backfillProgress as { cursors: Record }) + .cursors.fetchHeartRate, + ).toMatchObject({ + at: new Date(fake.now - 29 * 60_000).toISOString(), + pages: 3, + }); + // Second attempt, the queued slice: the finished collections are not - // read again. + // read again, and the stream starts at its edge, not at its newest page: + // the 31 samples from the edge on are four pages, not six. fake.requestsByPath = new Map(); await runGoogleHealthBackfillForUser(USER, undefined, 1); @@ -408,7 +433,7 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () expect( fake.requestsByPath.get("steps/dataPoints:dailyRollUp"), ).toBeUndefined(); - expect(fake.requestsByPath.get("heart-rate/dataPoints")).toBe(6); + expect(fake.requestsByPath.get("heart-rate/dataPoints")).toBe(4); expect(await pulseRows()).toBe(60); const afterSecond = await getPrismaClient().googleHealthConnection.findUnique({ @@ -418,7 +443,7 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () expect(afterSecond?.backfillProgress).toBeNull(); }); - it("keeps failing a backfill whose slice finished nothing new", async () => { + it("continues a slice that only moved the stream's edge, and fails one that moved nothing", async () => { const { runGoogleHealthBackfillForUser } = await import("@/lib/jobs/google-health-backfill"); @@ -428,13 +453,29 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () await runGoogleHealthBackfillForUser(USER, stopAtThirdPage); expect(sent.jobs).toHaveLength(1); - // The next slice cannot get through the heart-rate stream either: no - // collection finishes, so this one is stuck and says so. + // The next slice finishes no collection either, but it moves the edge + // thirty samples further back: progress, so it continues. fake.requestsByPath = new Map(); await expect( runGoogleHealthBackfillForUser(USER, stopAtThirdPage, 1), - ).rejects.toThrow(/incomplete/); - expect(sent.jobs).toHaveLength(1); + ).resolves.toMatchObject({ continued: true }); + expect(sent.jobs).toHaveLength(2); + + // A slice whose every heart-rate request fails moves nothing: it is + // stuck, says where, and queues nothing. + const { GOOGLE_HEALTH_TRANSIENT_RETRY } = + await import("@/lib/google-health/client"); + const baseDelay = GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs; + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; + fake.failHeartRate = true; + try { + await expect( + runGoogleHealthBackfillForUser(USER, undefined, 2), + ).rejects.toThrow(/no progress in slice 2; fetchHeartRate:failed/); + } finally { + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = baseDelay; + } + expect(sent.jobs).toHaveLength(2); const row = await getPrismaClient().googleHealthConnection.findUnique({ where: { userId: USER }, }); @@ -459,27 +500,32 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () } expect(fake.holdAtPage).toBe(-1); - const requestsBefore = [...fake.requestsByPath.values()].reduce( - (a, b) => a + b, - 0, - ); + const total = () => + [...fake.requestsByPath.values()].reduce((a, b) => a + b, 0); + const requestsBefore = total(); const manual = await syncUserGoogleHealth(USER); expect(manual.busy).toBe(true); - const hourly = await handleGoogleHealthSync([] as never); - expect(hourly).toMatchObject({ - ok: true, - did: { users_skipped: 1, users_parked: 0, users_failed: 0 }, + // The hourly poll waits for the account instead of skipping it: it does + // nothing while the backfill holds the lock ... + let hourlySettled = false; + const hourly = handleGoogleHealthSync([] as never).finally(() => { + hourlySettled = true; }); - const requestsAfter = [...fake.requestsByPath.values()].reduce( - (a, b) => a + b, - 0, - ); - expect(requestsAfter).toBe(requestsBefore); + await new Promise((r) => setTimeout(r, 2_500)); + expect(hourlySettled).toBe(false); + expect(total()).toBe(requestsBefore); + // ... and runs once the backfill lets go. fake.release?.(); await backfill; + const requestsAfterBackfill = total(); + expect(await hourly).toMatchObject({ + ok: true, + did: { users_skipped: 0, users_parked: 0, users_failed: 0 }, + }); + expect(total()).toBeGreaterThan(requestsAfterBackfill); expect(await pulseRows()).toBe(60); expect(await ledgerState()).toBe("connected"); - }); + }, 60_000); }); From c8194c4a867b15f7d91dc5616e6dfc681516995f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:45:15 +0200 Subject: [PATCH 10/40] test(google-health): drive a three-year heart-rate backfill through the drain to completion 1.58 million readings, paged by a generated fake of the list API that honours the edge and lower-bound filters, with a server error, a timeout and an hour-long rate limit injected. Pins that the backfill completes over several slices, imports every reading exactly once without rewriting a row, lets the hourly sync run between and beside slices, writes one log event per slice with its requests and edges, and keeps the heap flat across the history under the production heap ceiling. --- .../google-health-backfill-multi-year.test.ts | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 tests/integration/google-health-backfill-multi-year.test.ts diff --git a/tests/integration/google-health-backfill-multi-year.test.ts b/tests/integration/google-health-backfill-multi-year.test.ts new file mode 100644 index 000000000..bf4f63995 --- /dev/null +++ b/tests/integration/google-health-backfill-multi-year.test.ts @@ -0,0 +1,396 @@ +/** + * #1220 — a Google Health history backfill over three years of heart rate, + * driven through the admission drain slice by slice until it completes, + * against Postgres. + * + * What went wrong. A backfill noted its progress only once a whole collection + * was walked to the end. Three years of heart rate at one reading a minute is + * some 1 600 pages, and an attempt that did not get through all of them (its + * budget, a rate limit it could not wait out, one failed request among + * thousands) started the next attempt from the newest page again. The account + * never got past its first unfinished collection, every attempt failed with + * "marker not stamped", and none of them left a line saying where it stopped, + * because the drain ran the import outside any log event. + * + * What this pins, end to end: + * - the backfill completes, through as many slices as the history needs, + * and every slice after the first starts where the one before it stopped; + * - every reading is imported exactly once: the rows match the history, the + * slices' import counts add up to it, and no row was ever rewritten; + * - a server error, a lost request and a rate limit Google will not lift + * for an hour each cost a retry or a slice boundary, never the walk; + * - the hourly sync runs between slices (and beside one, by waiting for + * it) instead of being skipped for as long as the import runs; + * - every slice writes a log event naming its requests and where it got to; + * - the heap does not grow with the history (run it under the production + * ceiling: `NODE_OPTIONS=--max-old-space-size=524`). + * + * Google is replaced at the `safeFetch` seam by a generator that builds each + * page on request from the filter and page token it is asked with, so the + * fixture never sits in memory. + */ +import v8 from "node:v8"; +import vm from "node:vm"; + +import type { Job } from "pg-boss"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { WideEvent } from "@/lib/logging/types"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "gh-multi-year-owner"; +const MINUTE = 60_000; +/** The newest reading: now, so the hourly sync's window lands on real data. */ +const NOW = Math.floor(Date.now() / MINUTE) * MINUTE; +/** Three years of heart rate, one reading a minute. */ +const POINTS = 3 * 365 * 24 * 60; + +/** + * How much the collected heap may grow over the second half of the history: + * from the first sample taken inside a page request once the second slice is + * over, to the most any later sample reaches. Every sample is taken mid-walk, + * so each carries the same in-flight working set (one page, the ORM's and the + * driver's buffers, some 35 MB here) and only growth is compared. The first + * two slices are left out because the first hourly sync and the first + * rate-limited slice load code paths once. Measured, not chosen: the line + * stays within half a MB of flat. A backfill that kept what it read would + * grow by hundreds of MB over the same stretch. + */ +const GROWTH_BUDGET_BYTES = 16 * 1024 * 1024; + +const fake = vi.hoisted(() => ({ + now: 0, + total: 0, + /** Heart-rate requests seen, the backfill's among them, and readings served. */ + heartRateRequests: 0, + backfillRequests: 0, + servedPoints: 0, + /** Faults to inject, keyed by the backfill request number they hit. */ + faults: new Map(), + onRequest: null as (() => void) | null, +})); + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + const respond = (body: unknown, status = 200, headers = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); + return { + ...actual, + // A plain function, not `vi.fn`: a mock records every result, and here + // each result is a page of a thousand readings. + safeFetch: async (url: string) => { + const u = new URL(url); + if (!u.pathname.endsWith("/dataTypes/heart-rate/dataPoints")) { + // Every other collection is empty for this account. + return respond({}); + } + fake.heartRateRequests += 1; + fake.onRequest?.(); + // The hourly sync always asks from a lower bound; the backfill never. + const filter = u.searchParams.get("filter") ?? ""; + const fromBackfill = !filter.includes(">="); + if (fromBackfill) fake.backfillRequests += 1; + const fault = fromBackfill + ? fake.faults.get(fake.backfillRequests) + : undefined; + if (fault) { + fake.faults.delete(fake.backfillRequests); + if (fault === "timeout") { + throw new actual.SafeFetchError("safeFetch aborted", "timeout"); + } + if (fault === "503") { + return respond({ error: { code: 503, status: "UNAVAILABLE" } }, 503); + } + return respond( + { error: { code: 429, status: "RESOURCE_EXHAUSTED" } }, + 429, + { "retry-after": "3600" }, + ); + } + + // Newest first, one reading a minute: reading `g` is `g` minutes old. + // The hourly sync asks from a lower bound (`>=`), a resumed backfill + // up to its edge (`<=`). + const from = /physical_time >= "([^"]+)"/.exec(filter)?.[1]; + const upTo = /physical_time <= "([^"]+)"/.exec(filter)?.[1]; + const newest = upTo + ? Math.max(0, Math.ceil((fake.now - Date.parse(upTo)) / MINUTE)) + : 0; + const oldest = from + ? Math.min( + fake.total - 1, + Math.floor((fake.now - Date.parse(from)) / MINUTE), + ) + : fake.total - 1; + const pageSize = Number(u.searchParams.get("pageSize") ?? 1000); + const page = Number(u.searchParams.get("pageToken") ?? 0); + const start = newest + page * pageSize; + const end = Math.min(oldest + 1, start + pageSize); + const dataPoints = []; + for (let g = start; g < end; g++) { + dataPoints.push({ + name: `users/me/dataTypes/heart-rate/dataPoints/${7_000_000_000 + g}`, + dataSource: { platform: "FITBIT" }, + heartRate: { + beatsPerMinute: String(52 + (g % 61)), + sampleTime: { + physicalTime: new Date(fake.now - g * MINUTE).toISOString(), + }, + }, + }); + } + fake.servedPoints += dataPoints.length; + return respond({ + dataPoints, + ...(end <= oldest ? { nextPageToken: String(page + 1) } : {}), + }); + }, + }; +}); + +/** Jobs the backfill and the hourly sync queue, recorded. */ +const sent = vi.hoisted(() => ({ + jobs: [] as Array<{ queue: string; data: unknown }>, +})); +vi.mock("@/lib/jobs/boss-instance", async (importOriginal) => ({ + ...(await importOriginal()), + getGlobalBoss: () => ({ + send: async (queue: string, data: unknown) => { + sent.jobs.push({ queue, data }); + return `job-${sent.jobs.length}`; + }, + }), +})); + +/** The log events the runs write, recorded instead of printed. */ +const logged = vi.hoisted(() => ({ events: [] as WideEvent[] })); +vi.mock("@/lib/logging/transports", async (importOriginal) => ({ + ...(await importOriginal()), + emitIfSampled: (event: WideEvent) => { + logged.events.push(event); + }, +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const forceGc = ((): (() => void) => { + v8.setFlagsFromString("--expose-gc"); + const gc = vm.runInNewContext("gc") as () => void; + v8.setFlagsFromString("--no-expose-gc"); + return gc; +})(); + +function liveHeapBytes(): number { + forceGc(); + forceGc(); + return process.memoryUsage().heapUsed; +} + +const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1); + +function report(line: string): void { + process.stderr.write(`[gh-multi-year] ${line}\n`); +} + +beforeAll(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + const { encrypt } = await import("@/lib/crypto"); + await prisma.user.create({ + data: { id: USER, username: USER, timezone: "Europe/Berlin" }, + }); + await prisma.googleHealthConnection.create({ + data: { + userId: USER, + googleUserId: `google-${USER}`, + accessToken: encrypt("access"), + refreshToken: encrypt("refresh"), + tokenExpiresAt: new Date(Date.now() + 7 * 86_400_000), + }, + }); + fake.now = NOW; + fake.total = POINTS; +}, 120_000); + +afterAll(async () => { + await truncateAllTables(getPrismaClient()); +}); + +type AdmissionPayload = + import("@/lib/jobs/integration-backfill-admission").IntegrationBackfillAdmissionPayload; + +function admissionJob( + payload: AdmissionPayload, + n: number, +): Job { + return { + id: `admission-${n}`, + name: "integration-backfill-admission", + data: payload, + // The production expiry: sixteen hours. + expireInSeconds: 16 * 60 * 60, + } as unknown as Job; +} + +function nextSlice(): AdmissionPayload | null { + const next = sent.jobs.find( + (j) => j.queue === "integration-backfill-admission", + ); + return next ? (next.data as AdmissionPayload) : null; +} + +describe("Google Health backfill over three years of heart rate (#1220)", () => { + it("completes slice by slice, imports every reading once, and keeps the hourly sync running", async () => { + const { drainIntegrationBackfillAdmission } = + await import("@/lib/jobs/integration-backfill-drain"); + const { handleGoogleHealthSync } = + await import("@/lib/jobs/reminder/google-health-sync"); + const { GOOGLE_HEALTH_TRANSIENT_RETRY } = + await import("@/lib/google-health/client"); + const { GOOGLE_HEALTH_BACKFILL_SLICE_PAGES } = + await import("@/lib/jobs/google-health-backfill"); + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; + + // A server error and a lost request in the first slice, and a rate limit + // Google will not lift for an hour in the second. + fake.faults.set(40, "503"); + fake.faults.set(80, "timeout"); + fake.faults.set(GOOGLE_HEALTH_BACKFILL_SLICE_PAGES + 120, "429-long"); + + // Heap samples from inside the page requests, between two pages of the + // work being measured, once the second slice is over (see the budget). + let measuring = false; + let anchor: number | null = null; + let peak = 0; + let samples = 0; + fake.onRequest = () => { + if (!measuring || fake.heartRateRequests % 50 !== 0) return; + const live = liveHeapBytes(); + if (anchor === null) { + anchor = peak = live; + return; + } + samples++; + peak = Math.max(peak, live); + }; + + const prisma = getPrismaClient(); + let payload: AdmissionPayload | null = { + kind: "google-health-backfill", + data: { userId: USER, enqueuedAt: new Date().toISOString() }, + }; + const slices: Array<{ + slice: number; + imported: number; + continued: number; + }> = []; + let hourlyBeside: Promise | null = null; + + for (let n = 0; payload && n < 40; n++) { + sent.jobs = []; + logged.events = []; + const drained = drainIntegrationBackfillAdmission([ + admissionJob(payload, n), + ]); + // Once, start the hourly sync while a slice is running: it waits for + // the slice instead of skipping the account. + if (n === 2) { + hourlyBeside = handleGoogleHealthSync([] as never); + } + const outcome = await drained; + expect(outcome.ok).toBe(true); + const did = (outcome as { did: Record }).did; + slices.push({ + slice: payload.data.slice ?? 0, + imported: did.imported, + continued: did.continued, + }); + + // The slice wrote one log event, with its requests and where it got to. + const runEvents = logged.events.filter( + (e) => e.background?.task_name === "job.integration_backfill", + ); + expect(runEvents).toHaveLength(1); + const sliceMeta = runEvents[0]!.meta?.["googleHealth.backfill.slice"] as + { pages: number; stopReason: string | null; edges: object } | undefined; + expect(sliceMeta).toBeDefined(); + expect( + runEvents[0]!.external_calls?.some((c) => + c.method.startsWith("fetchHeartRate(page="), + ), + ).toBe(true); + report( + `slice ${payload.data.slice ?? 0}: pages=${sliceMeta!.pages} ` + + `stop=${sliceMeta!.stopReason} imported=${did.imported} ` + + `edges=${JSON.stringify(sliceMeta!.edges)}`, + ); + + if (n === 1) measuring = true; + payload = nextSlice(); + + // Between slices the account is free, and the hourly sync runs. + if (payload) { + const hourly = await handleGoogleHealthSync([] as never); + expect(hourly).toMatchObject({ + ok: true, + did: { users_skipped: 0, users_failed: 0, users_parked: 0 }, + }); + } + } + + expect(await hourlyBeside).toMatchObject({ + ok: true, + did: { users_skipped: 0 }, + }); + + // Completed, and the note of how far it got is gone with it. + const connection = await prisma.googleHealthConnection.findUniqueOrThrow({ + where: { userId: USER }, + select: { backfillCompletedAt: true, backfillProgress: true }, + }); + expect(connection.backfillCompletedAt).toBeInstanceOf(Date); + expect(connection.backfillProgress).toBeNull(); + expect(payload).toBeNull(); + + // More than one slice, every one before the last continued, and the + // rate-limited one among them. + expect(slices.length).toBeGreaterThan(2); + expect(slices.slice(0, -1).every((s) => s.continued === 1)).toBe(true); + expect(fake.faults.size).toBe(0); + expect(slices.map((s) => s.slice)).toEqual(slices.map((_, i) => i)); + + // Every reading exactly once. + const rows = await prisma.measurement.count({ + where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" }, + }); + expect(rows).toBe(POINTS); + expect(slices.reduce((sum, s) => sum + s.imported, 0)).toBe(POINTS); + const versions = await prisma.measurement.aggregate({ + where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" }, + _max: { syncVersion: true }, + _min: { syncVersion: true }, + }); + expect(versions._max.syncVersion).toBe(versions._min.syncVersion); + // Each slice re-reads only the reading on its edge (plus the hourly + // sync's own window), not the history before it. + report( + `requests=${fake.heartRateRequests} served=${fake.servedPoints} ` + + `points=${POINTS} slices=${slices.length}`, + ); + + // The heap did not grow with the history. + const growth = peak - (anchor ?? 0); + report( + `heap growth after the second slice: ${mb(growth)} MB over ${samples} ` + + `samples (budget ${mb(GROWTH_BUDGET_BYTES)} MB, ceiling ` + + `${mb(v8.getHeapStatistics().heap_size_limit)} MB, peak ${mb(peak)} MB)`, + ); + expect(samples).toBeGreaterThan(5); + expect(growth).toBeLessThan(GROWTH_BUDGET_BYTES); + }, 3_600_000); +}); From 7be05608f8e678fc7e06dc8db7cbdc538ab281dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 12:23:09 +0200 Subject: [PATCH 11/40] test: date fixtures that feed a clock-relative window relative to now A fixture dated near the day it was written passes until the date slides out of a window the route counts back from the clock. Five did: a snooze until a fixed date that becomes the past in January, a prognosis read against the wall clock over days seeded at a fixed date, sleep rows and a weight streak read through windows that end now, and a sync token that would expire on a fixed date. The e2e mood heatmap mock and a timeline URL are dated from today as well. A guard scans the integration and e2e trees for near-now absolute dates within reach of a window parameter, with an allowlist for files whose windows are fixed on both ends. --- e2e/timeline.spec.ts | 2 +- e2e/utils/mock-mood-insights.ts | 12 +- .../aging-fixture-dates-guard.test.ts | 203 ++++++++++++++++++ .../google-health-sync-progress.test.ts | 4 +- .../integration/mood-prognosis-ladder.test.ts | 18 +- .../sharing-manage-handler-matrix.test.ts | 7 +- .../source-priority-two-axis.test.ts | 23 +- tests/integration/timezone-per-user.test.ts | 9 +- 8 files changed, 263 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/aging-fixture-dates-guard.test.ts diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 39b4d40fb..37a03977c 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -560,7 +560,7 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy // A parameter it cannot read opens the same default as no zoom: this // record has entries in the last three months, so three months. - await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01"); + await page.goto(`/timeline?zoom=range&from=nonsense&to=${TODAY}`); await expect(zoom("quarter")).toHaveAttribute("aria-checked", "true", { timeout: 20_000, }); diff --git a/e2e/utils/mock-mood-insights.ts b/e2e/utils/mock-mood-insights.ts index 36fe5c144..a85ceb457 100644 --- a/e2e/utils/mock-mood-insights.ts +++ b/e2e/utils/mock-mood-insights.ts @@ -23,13 +23,21 @@ function correlation(r: number, strength: string, n: number) { }; } +/** + * A calendar day this many days back, so the heatmap cells stay inside its + * 90-day window whenever the suite runs. + */ +function dayKeyDaysAgo(days: number): string { + return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); +} + const MOOD_INSIGHTS_PAYLOAD = { summary: { totalEntries: 269, inTargetPct: 62 }, heatmap: { windowDays: 90, cells: [ - { date: "2026-07-01", score: 4, samples: 2 }, - { date: "2026-07-02", score: 3, samples: 1 }, + { date: dayKeyDaysAgo(14), score: 4, samples: 2 }, + { date: dayKeyDaysAgo(13), score: 3, samples: 1 }, ], }, distribution: [], diff --git a/src/__tests__/aging-fixture-dates-guard.test.ts b/src/__tests__/aging-fixture-dates-guard.test.ts new file mode 100644 index 000000000..b4db923dd --- /dev/null +++ b/src/__tests__/aging-fixture-dates-guard.test.ts @@ -0,0 +1,203 @@ +/** + * Guard: an integration or e2e fixture dated near "now" must not feed a time + * window. + * + * A fixture written today with an absolute date (`new Date("2026-07-15…")`, + * `?date=2026-07-02`) next to a window parameter passes on the day it is + * written and fails months later, when the date slides out of a window the + * route counts back from the clock. The suite then goes red on a release + * branch that changed nothing near it, and the fix is archaeology. That has + * happened more than once (chart tabs, the 90-day share-link window). + * + * The rule: an absolute ISO date within the last 400 days (or in the future) + * that sits within two lines of a window parameter (`days=`, `from=`, `to=`, + * `since`, `until`, `date=`, `range=`, `rangeDays`, `windowDays`, + * `lookbackDays`) is a + * finding. Fix it by dating the fixture relative to now + * (`new Date(Date.now() - 3 * DAY_MS)`, a `daysAgo(n)` helper), or, when the + * window is fixed on both ends and the route never measures it against the + * clock, allowlist the file below with that reason and the number of findings + * it may carry, so a new one in the same file still fails. + * + * What it cannot see: a fixture date and a relative window that are further + * apart than two lines (a seed helper at the top of the file, `days=90` in a + * request at the bottom). It is a textual net for the common shape, not a + * proof; the clock-shifted run described in the allowlist reasons is what + * proved the allowlisted files fixed. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(__dirname, "..", ".."); +const SCAN_ROOTS = ["tests/integration", "e2e"]; +const DAY_MS = 86_400_000; +/** How far back an absolute date still counts as "near now". */ +const HORIZON_DAYS = 400; +/** Lines either side of a date searched for a window parameter. */ +const NEIGHBOURHOOD = 2; + +const ISO_DATE = /\b(20\d\d)-(\d\d)-(\d\d)/g; +const WINDOW_PARAM = + /[?&](?:days|from|to|since|until|date|range|rangeDays|windowDays)=|\b(?:since|until|rangeDays|windowDays|lookbackDays)\b\s*[:=]/; + +/** + * Files whose flagged dates feed windows fixed on both ends, which the routes + * never compare with the clock. `max` is the number of findings the file may + * carry; one more fails. + */ +const ALLOWLIST: Record = { + // Each entry passed with the test clock moved a year forward, which is what + // an aging fixture fails under; the reason says why it cannot age. + "tests/integration/day-view.test.ts": { + max: 21, + reason: + "day index, timeline and notable reads are asked for explicit from/to windows; the reminder since/until params are fixture data, not a window counted from now", + }, + "tests/integration/sharing-delegable-routes.test.ts": { + max: 5, + reason: + "day-log reads name one explicit calendar day (?date=), never a window counted from now", + }, + "tests/integration/measurements-aggregate-daily.test.ts": { + max: 5, + reason: "every series read passes both from and to", + }, + "tests/integration/cycle-routes.test.ts": { + max: 5, + reason: + "day-log reads name one explicit day and the calendar read passes both from and to", + }, + "tests/integration/medication-auto-mark-taken.test.ts": { + max: 2, + reason: "the cases pin the clock with fake timers around the fixed dates", + }, + "tests/integration/symptoms.test.ts": { + max: 1, + reason: + "the discovery matrix reads from a fixed since forward; the fixture days stay inside it as now moves on", + }, +}; + +export interface AgingFinding { + file: string; + line: number; + text: string; +} + +/** Every near-now absolute date within reach of a window parameter. */ +export function findAgingFixtureDates( + files: ReadonlyArray<{ file: string; source: string }>, + now: number, +): AgingFinding[] { + const floor = now - HORIZON_DAYS * DAY_MS; + const findings: AgingFinding[] = []; + for (const { file, source } of files) { + const lines = source.split("\n"); + lines.forEach((text, index) => { + for (const match of text.matchAll(ISO_DATE)) { + const at = Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + ); + if (Number.isNaN(at) || at < floor) continue; + const around = lines + .slice(Math.max(0, index - NEIGHBOURHOOD), index + NEIGHBOURHOOD + 1) + .join("\n"); + if (WINDOW_PARAM.test(around)) { + findings.push({ file, line: index + 1, text: text.trim() }); + break; + } + } + }); + } + return findings; +} + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(full); + return /\.(ts|tsx|mts)$/.test(entry.name) ? [full] : []; + }); +} + +function scannedFiles() { + return SCAN_ROOTS.flatMap((root) => walk(path.join(ROOT, root))).map( + (full) => ({ + file: path.relative(ROOT, full), + source: fs.readFileSync(full, "utf8"), + }), + ); +} + +describe("aging fixture dates", () => { + const files = scannedFiles(); + + it("scans the integration and e2e trees (never vacuously)", () => { + expect(files.length).toBeGreaterThan(300); + expect(files.some((f) => f.file.startsWith("e2e/"))).toBe(true); + }); + + it("no near-now absolute date feeds a window outside the allowlist", () => { + const findings = findAgingFixtureDates(files, Date.now()); + const byFile = new Map(); + for (const finding of findings) { + const list = byFile.get(finding.file) ?? []; + list.push(finding); + byFile.set(finding.file, list); + } + const offenders: string[] = []; + for (const [file, list] of byFile) { + const allowed = ALLOWLIST[file]?.max ?? 0; + if (list.length > allowed) { + offenders.push( + ...list.map( + (f) => + `${f.file}:${f.line} ${f.text}\n -> date it relative to now (Date.now() - n * DAY_MS), or allowlist a fixed window with its reason`, + ), + ); + } + } + expect(offenders, offenders.join("\n")).toEqual([]); + }); + + it("every allowlist entry still names a file with findings", () => { + // A stale entry would quietly allow the next finding in that file. + const findings = findAgingFixtureDates(files, Date.now()); + for (const file of Object.keys(ALLOWLIST)) { + expect( + findings.some((f) => f.file === file), + `${file} has no findings left; drop its allowlist entry`, + ).toBe(true); + } + }); + + it("flags a near-now fixture next to a window, and nothing older", () => { + const now = Date.UTC(2026, 9, 15); + const findings = findAgingFixtureDates( + [ + { + file: "a.test.ts", + source: [ + 'const takenAt = new Date("2026-07-15T08:00:00Z");', + 'await get("/api/series?kind=pulse&days=90");', + ].join("\n"), + }, + { file: "b.test.ts", source: 'get("/api/day-logs?date=2026-07-02");' }, + { + file: "c.test.ts", + source: 'get("/api/labs?from=2019-04-01T00:00:00Z&to=2019-05-01");', + }, + { file: "d.test.ts", source: 'const at = new Date("2026-07-15");' }, + ], + now, + ); + expect(findings.map((f) => `${f.file}:${f.line}`)).toEqual([ + "a.test.ts:1", + "b.test.ts:1", + ]); + }); +}); diff --git a/tests/integration/google-health-sync-progress.test.ts b/tests/integration/google-health-sync-progress.test.ts index 0a80f416c..45f857cd6 100644 --- a/tests/integration/google-health-sync-progress.test.ts +++ b/tests/integration/google-health-sync-progress.test.ts @@ -42,7 +42,9 @@ async function seedConnection(userId: string): Promise { googleUserId: `google-${userId}`, accessToken: "encrypted-access", refreshToken: "encrypted-refresh", - tokenExpiresAt: new Date("2027-01-01T00:00:00.000Z"), + // Valid for a year from whenever the suite runs: a fixed date would + // expire and send the sync down the refresh path. + tokenExpiresAt: new Date(Date.now() + 365 * 86_400_000), }, }); } diff --git a/tests/integration/mood-prognosis-ladder.test.ts b/tests/integration/mood-prognosis-ladder.test.ts index 888e69581..7176734b4 100644 --- a/tests/integration/mood-prognosis-ladder.test.ts +++ b/tests/integration/mood-prognosis-ladder.test.ts @@ -15,7 +15,7 @@ * tooltip. */ import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; @@ -46,7 +46,14 @@ vi.mock("@/lib/db-compat", () => ({ })); /** Fixed clock: a ladder test that slides with the calendar is a date bomb. */ -const NOW = new Date("2026-08-08T09:00:00.000Z"); +const FIXED_NOW = new Date("2026-08-08T09:00:00.000Z"); +/** + * The instant the seed and the job are dated against. The ladder cases keep + * the fixed clock; the route cases move it to the real one, because the route + * reads the forecast against the wall clock and a forecast seeded at a fixed + * date goes stale under it as the calendar moves on. + */ +let NOW = FIXED_NOW; function dayKey(daysAgo: number): string { return new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString().slice(0, 10); @@ -271,6 +278,13 @@ describe("the minimum-n ladder, end to end", () => { }); describe("GET /api/mood/prognosis", () => { + beforeEach(() => { + NOW = new Date(); + }); + afterEach(() => { + NOW = FIXED_NOW; + }); + /** * A signed-in browser session. * diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 34583f305..6b7d83fce 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -795,7 +795,12 @@ manageContract("POST /api/measurement-reminders/[id]/snooze", { POST as Handler, "POST", `/api/measurement-reminders/${row.id}/snooze`, - { until: "2027-01-15" }, + // A snooze must end in the future; a fixed date stops being one. + { + until: new Date(Date.now() + 90 * 86_400_000) + .toISOString() + .slice(0, 10), + }, { id: row.id }, ); }, diff --git a/tests/integration/source-priority-two-axis.test.ts b/tests/integration/source-priority-two-axis.test.ts index b90ef2bd7..4710e0740 100644 --- a/tests/integration/source-priority-two-axis.test.ts +++ b/tests/integration/source-priority-two-axis.test.ts @@ -34,6 +34,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +/** + * The night the fixtures sleep, ten days back from whenever the suite runs. + * The analytics route summarises a window that ends now; a fixed date slides + * out of it as the calendar moves on. + */ +const SLEEP_DAY = new Date(Date.now() - 10 * 86_400_000) + .toISOString() + .slice(0, 10); + vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { @@ -130,7 +139,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 420, unit: "minutes", source: "WITHINGS", - measuredAt: new Date("2026-05-12T07:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`), }, }); @@ -162,7 +171,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-1", }, }); @@ -174,7 +183,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 410, unit: "minutes", source: "WITHINGS", - measuredAt: new Date("2026-05-12T07:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`), }, }); @@ -200,7 +209,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 380, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-watch", deviceType: "watch", }, @@ -212,7 +221,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:30:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`), externalId: "ah-sleep-phone", deviceType: "phone", }, @@ -240,7 +249,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-watch", deviceType: "watch", }, @@ -252,7 +261,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 380, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:30:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`), externalId: "ah-sleep-phone", deviceType: "phone", }, diff --git a/tests/integration/timezone-per-user.test.ts b/tests/integration/timezone-per-user.test.ts index 8a191e52a..218764d21 100644 --- a/tests/integration/timezone-per-user.test.ts +++ b/tests/integration/timezone-per-user.test.ts @@ -420,8 +420,15 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => { // doesn't trigger a tz divergence on its own. The point is to // confirm the route returns a non-zero streak and goes through // the user-tz path without crashing. + // Three consecutive days ending a week back: the summary reads its + // streaks from a window that ends now, so fixed dates would slide out. + const firstDay = Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate() - 9, + ); for (let i = 0; i < 3; i++) { - const dt = new Date(`2026-05-${12 + i}T11:30:00.000Z`); + const dt = new Date(firstDay + i * 86_400_000 + 11.5 * 3_600_000); await prisma.measurement.create({ data: { userId: me.id, From e2d5bf121cde0757c77d4adb7dfb5fa0ecf62d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 12:31:39 +0200 Subject: [PATCH 12/40] fix(google-health): read past the resume edge when Google refuses its filter --- src/lib/google-health/client.ts | 326 +++++++++++++----- src/lib/google-health/sync-core.ts | 83 ++++- src/lib/google-health/sync-metrics.ts | 21 +- .../__tests__/google-health-backfill.test.ts | 2 + src/lib/jobs/google-health-backfill.ts | 8 + 5 files changed, 341 insertions(+), 99 deletions(-) diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 1bcdee440..10bb8c07d 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -43,10 +43,11 @@ import { classifyGoogleHealthResponse, type GoogleHealthClassification, } from "./response-classifier"; -import type { - GoogleHealthDataPoint, - GoogleHealthDataType, - GoogleHealthRollupPoint, +import { + googleHealthSampleInstant, + type GoogleHealthDataPoint, + type GoogleHealthDataType, + type GoogleHealthRollupPoint, } from "./mappers"; import { envValue } from "@/lib/env"; @@ -165,6 +166,12 @@ export interface GoogleHealthSliceBudget { deadline: number | null; maxPages: number; pages: number; + /** + * Of `pages`, those a walk only passed over on its way to its edge, because + * Google refused the edge filter (see `forEachDataPointPage`). They are not + * charged against `maxPages`, and the time they took moves `deadline` on. + */ + skippedPages: number; stopReason: GoogleHealthSliceStopReason | null; now: () => number; } @@ -180,6 +187,7 @@ export function googleHealthSliceBudget( deadline: null, maxPages, pages: 0, + skippedPages: 0, stopReason: null, now, }; @@ -192,7 +200,9 @@ function sliceExhausted(slice: GoogleHealthSliceBudget): boolean { if (slice.stopReason) return true; slice.deadline ??= slice.now() + slice.ms; if (slice.now() >= slice.deadline) slice.stopReason = "time_budget"; - else if (slice.pages >= slice.maxPages) slice.stopReason = "page_budget"; + else if (slice.pages - slice.skippedPages >= slice.maxPages) { + slice.stopReason = "page_budget"; + } return slice.stopReason !== null; } @@ -242,6 +252,22 @@ function noteSlicePage(): void { if (slice) slice.pages += 1; } +/** + * Give back to the slice in scope the pages and time a walk spent passing + * over samples beyond its edge (no-op outside a slice). + */ +function refundSliceSkip(pages: number, ms: number): void { + const slice = stopStorage.getStore()?.slice; + if (!slice) return; + slice.skippedPages += pages; + if (slice.deadline !== null) slice.deadline += ms; +} + +/** The slice clock in scope, or the wall clock outside a slice. */ +function sliceNow(): number { + return stopStorage.getStore()?.slice?.now() ?? Date.now(); +} + /** End the slice in scope for `reason` (no-op outside a slice). */ function endSlice(reason: GoogleHealthSliceStopReason): void { const slice = stopStorage.getStore()?.slice; @@ -716,8 +742,22 @@ interface DataPointQuery { * when the walk ran newest first (`desc`), the newest when it ran oldest * first (`asc`). The walk asks only for the samples at or beyond it; the * one sample on the edge itself is read again and overwrites in place. + * + * `filter: false` reads without the edge bound and passes over the samples + * beyond the edge here instead (see `forEachDataPointPage`); the walk falls + * back to that on its own when Google refuses the bound, and calls + * `onFilterUnsupported` once when it does. `tz` reads an offset-less sample + * time the way the edge was recorded. `maxSkipPages` bounds how many pages + * the walk may pass over before it reaches the edge. */ - edge?: { at: Date; order: "desc" | "asc" }; + edge?: { + at: Date; + order: "desc" | "asc"; + filter?: boolean; + tz?: string; + onFilterUnsupported?: () => void | Promise; + maxSkipPages?: number; + }; } const pad2 = (n: number): string => String(n).padStart(2, "0"); @@ -984,7 +1024,10 @@ async function googleHealthDataRequest( token: GoogleHealthAccessToken, label: string, send: (accessToken: string) => Promise, - isExpectedFallback: (status: number) => boolean = () => false, + expectedFallbackNote: ( + status: number, + apiErrorDetail: string | undefined, + ) => string | undefined = () => undefined, ): Promise> { let rateLimited = 0; let transient = 0; @@ -1060,8 +1103,8 @@ async function googleHealthDataRequest( method: label, duration_ms: Math.round(performance.now() - started), status: res.status, - ...(isExpectedFallback(res.status) - ? { note: "date_filter_rejected_retrying_snake_case" } + ...(expectedFallbackNote(res.status, apiErrorDetail) + ? { note: expectedFallbackNote(res.status, apiErrorDetail) } : willWait ? { note: `rate_limited_retrying_in_ms=${backoffMs}` } : willRefresh @@ -1152,99 +1195,163 @@ export async function forEachDataPointPage( const maxPages = query.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES; const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE; const resumable = query.resumable === true; + const edge = query.edge; let requestCount = 0; const walk = async ( dateStyle: GoogleHealthDateFilterStyle, + edgeBy: "filter" | "skip", ): Promise => { let fetched = 0; let pageToken: string | null | undefined; let pageCount = 0; let stopped = false; + // Reading without the edge bound: the pages before the edge are passed + // over, not written, until the first sample at or beyond it. + const skipTo = edge && edgeBy === "skip" ? edge : undefined; + let crossed = skipTo === undefined; + let skipped = 0; + const skipStartedAt = skipTo ? sliceNow() : 0; + const maxSkip = skipTo?.maxSkipPages ?? GOOGLE_HEALTH_DENSE_MAX_PAGES; + const beyondEdge = (point: GoogleHealthDataPoint): boolean | null => { + const t = googleHealthSampleInstant(point, dataType, skipTo?.tz); + if (t === null || !skipTo) return null; + return skipTo.order === "desc" + ? t.getTime() > skipTo.at.getTime() + : t.getTime() < skipTo.at.getTime(); + }; + const refundSkip = () => { + if (skipped > 0) refundSliceSkip(skipped, sliceNow() - skipStartedAt); + skipped = 0; + }; - do { - if (stopRequested(resumable)) { - stopped = true; - break; - } - const params = new URLSearchParams({ pageSize: String(pageSize) }); - const filters: string[] = []; - if (query.start) { - const { field, bound } = incrementalFilter( - dataType, - query.start, - query.tz, - dateStyle, - ); - filters.push(`${field} >= "${bound}"`); - } - if (query.edge) { - const { field, bound } = incrementalFilter( - dataType, - query.edge.at, - query.tz, - dateStyle, - ); - filters.push( - `${field} ${query.edge.order === "desc" ? "<=" : ">="} "${bound}"`, - ); - } - if (filters.length > 0) params.set("filter", filters.join(" AND ")); - if (pageToken) params.set("pageToken", pageToken); + try { + do { + // A walk still passing over the samples beyond its edge does not stop + // on the slice's budget, only on the job's: the skip writes nothing, + // so a slice that stopped inside it would have nothing to show, and + // the next slice would start the same skip over (see + // `GoogleHealthSliceBudget.skippedPages`). + if (crossed ? stopRequested(resumable) : stopRequested(false)) { + stopped = true; + break; + } + if (!crossed && skipped >= maxSkip) { + getEvent()?.addWarning( + `google-health ${verb}: passed over ${skipped} pages without reaching the resume edge`, + ); + stopped = true; + break; + } + const params = new URLSearchParams({ pageSize: String(pageSize) }); + const filters: string[] = []; + if (query.start) { + const { field, bound } = incrementalFilter( + dataType, + query.start, + query.tz, + dateStyle, + ); + filters.push(`${field} >= "${bound}"`); + } + const edgeFiltered = edge !== undefined && edgeBy === "filter"; + if (edgeFiltered) { + const { field, bound } = incrementalFilter( + dataType, + edge.at, + query.tz, + dateStyle, + ); + filters.push( + `${field} ${edge.order === "desc" ? "<=" : ">="} "${bound}"`, + ); + } + if (filters.length > 0) params.set("filter", filters.join(" AND ")); + if (pageToken) params.set("pageToken", pageToken); - requestCount += 1; - // v1.42 (#1023) — the first filtered daily-summary read answering 400 - // is the expected trigger for the snake_case retry below, not a - // failure: some accounts reject the documented camelCase prefix on - // some types, and the very next request answers 200. It was logged as - // an error next to the 200 that followed it. It is recorded as a note - // now; any other 400 still carries its error. - const firstRequest = requestCount === 1; - const { status, json, verdict, apiErrorDetail } = - await googleHealthDataRequest( - accessToken, - `${verb}(page=${pageCount})`, - (token) => - safeFetch( - `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`, - { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - }, - ), - (status) => - canFallBack && - dateStyle === "camel" && - firstRequest && - status === 400, - ); - // A rate limit the request could not wait out ends a resumable walk - // inside a backfill slice where it is: the pages before it are written - // and their edge recorded, so this is a pause, not a failure. The slice - // ends with it, since every other walk would meet the same quota. - if (status === 429 && resumable && inSlice()) { - endSlice("rate_limited"); - stopped = true; - break; - } - if (verdict.classification !== "success") { - throw new GoogleHealthApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: apiErrorDetail, - }); - } + requestCount += 1; + // v1.42 (#1023) — the first filtered daily-summary read answering 400 + // is the expected trigger for the snake_case retry below, not a + // failure: some accounts reject the documented camelCase prefix on + // some types, and the very next request answers 200. It was logged as + // an error next to the 200 that followed it. It is recorded as a note + // now; any other 400 still carries its error. The same holds for the + // first resumed request refusing the edge bound. + const firstRequest = requestCount === 1; + const firstOfWalk = pageCount === 0; + const edgeRefused = (status: number, detail: string | undefined) => + edgeFiltered && + firstOfWalk && + status === 400 && + refusesEdgeFilter(detail); + const { status, json, verdict, apiErrorDetail } = + await googleHealthDataRequest( + accessToken, + `${verb}(page=${pageCount})`, + (token) => + safeFetch( + `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`, + { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }, + ), + (status, detail) => + canFallBack && + dateStyle === "camel" && + firstRequest && + status === 400 + ? "date_filter_rejected_retrying_snake_case" + : edgeRefused(status, detail) + ? "edge_filter_rejected_reading_without_it" + : undefined, + ); + if (edgeRefused(status, apiErrorDetail)) { + throw new EdgeFilterRefused(); + } + // A rate limit the request could not wait out ends a resumable walk + // inside a backfill slice where it is: the pages before it are written + // and their edge recorded, so this is a pause, not a failure. The slice + // ends with it, since every other walk would meet the same quota. + if (status === 429 && resumable && inSlice()) { + endSlice("rate_limited"); + stopped = true; + break; + } + if (verdict.classification !== "success") { + throw new GoogleHealthApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + upstreamError: apiErrorDetail, + }); + } - const page = json?.dataPoints ?? []; - pageToken = json?.nextPageToken ?? null; - pageCount += 1; - fetched += page.length; - noteSlicePage(); - await onPage(page); - } while (pageToken && pageCount < maxPages); + let page = json?.dataPoints ?? []; + pageToken = json?.nextPageToken ?? null; + pageCount += 1; + fetched += page.length; + noteSlicePage(); + if (!crossed) { + // Placed samples beyond the edge were written by an earlier slice; + // one without a sample time cannot be placed and is passed over + // with them until the walk is past the edge. + const kept = page.filter((p) => beyondEdge(p) === false); + if (kept.length === 0) { + skipped += 1; + continue; + } + crossed = true; + refundSkip(); + page = page.filter((p) => beyondEdge(p) !== true); + } + await onPage(page); + } while (pageToken && pageCount - skipped < maxPages); + } finally { + refundSkip(); + } notePagination(pageCount, fetched, stopped || Boolean(pageToken)); }; @@ -1255,8 +1362,20 @@ export async function forEachDataPointPage( dataType.timeField === "date" && query.start !== undefined; let style: GoogleHealthDateFilterStyle = "camel"; + let edgeBy: "filter" | "skip" = edge?.filter === false ? "skip" : "filter"; + const run = async (dateStyle: GoogleHealthDateFilterStyle) => { + try { + await walk(dateStyle, edgeBy); + } catch (err) { + if (!(err instanceof EdgeFilterRefused)) throw err; + // Once: the walk without the bound sends no edge filter to refuse. + edgeBy = "skip"; + await edge?.onFilterUnsupported?.(); + await walk(dateStyle, edgeBy); + } + }; try { - await walk("camel"); + await run("camel"); } catch (err) { const firstRequestRejected = canFallBack && @@ -1265,7 +1384,7 @@ export async function forEachDataPointPage( err.httpStatus === 400; if (!firstRequestRejected) throw err; style = "snake"; - await walk("snake"); + await run("snake"); } if (canFallBack) { annotate({ meta: { "googleHealth.dateFilter.style": style } }); @@ -1273,6 +1392,31 @@ export async function forEachDataPointPage( } } +/** + * Thrown inside a walk whose first request Google refused because of the + * resume edge's bound; `forEachDataPointPage` reads the collection again + * without it. + */ +class EdgeFilterRefused extends Error { + constructor() { + super("google-health: edge filter refused"); + } +} + +/** + * Whether a 400 on the first resumed request is Google refusing the edge + * bound, rather than something else about the request. The request differs + * from one Google answers every hour only by that bound, so a bare 400 or an + * INVALID_ARGUMENT counts, and so does any message naming the filter; one + * naming the page token, the page size or the access token does not. + */ +export function refusesEdgeFilter(detail: string | undefined): boolean { + if (detail === undefined) return true; + if (/filter/i.test(detail)) return true; + if (/token|page.?size/i.test(detail)) return false; + return /INVALID_ARGUMENT/.test(detail); +} + // ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ───────────────── /** diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index 80004abf5..aea8d7d19 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -426,6 +426,13 @@ export async function runWithGoogleHealthSyncCycle( * years that one attempt could not finish (its budget, a rate limit, one * failed page among thousands) started again from the newest page on every * attempt and never reached the end (#1220). + * + * The upper bound (`<=`) a newest-first walk resumes with is valid filter + * grammar but has not been seen working against the live API. A collection + * whose resumed request Google refuses with a 400 is noted in + * `edgeFilterUnsupported`, and its walks from then on read without the bound + * and pass over the samples beyond the edge themselves (see + * `forEachDataPointPage`). */ export interface GoogleHealthBackfillCursor { /** The edge: the sample instant (ISO 8601) the walk has written up to. */ @@ -462,6 +469,8 @@ interface BackfillResumeTracker { /** Collections whose edge this attempt moved, with the edge they started from. */ advanced: Map; collections: GoogleHealthBackfillCollectionReport[]; + /** Collections whose resumed request Google refused the edge filter on. */ + edgeFilterUnsupported: Set; } const backfillResumeStorage = new AsyncLocalStorage(); @@ -506,6 +515,23 @@ export function readGoogleHealthBackfillCursors( return out; } +/** + * Read the `backfillProgress` column into the collections Google refused the + * edge filter on. + */ +export function readGoogleHealthBackfillEdgeFilterUnsupported( + value: unknown, +): Set { + const keys = progressRecord(value).edgeFilterUnsupported; + return new Set( + Array.isArray(keys) + ? keys.filter( + (key): key is string => typeof key === "string" && key.length <= 64, + ) + : [], + ); +} + /** * Write the tracker's progress with one change applied, and apply it to the * tracker only once the write has landed: progress that is not on disk is @@ -516,8 +542,10 @@ async function persistBackfillProgress( next: { done: Set; cursors: Map; + edgeFilterUnsupported?: Set; }, ): Promise { + const unsupported = next.edgeFilterUnsupported ?? tracker.edgeFilterUnsupported; await prisma.googleHealthConnection.update({ where: { userId: tracker.userId }, data: { @@ -529,9 +557,17 @@ async function persistBackfillProgress( { at: c.at, order: c.order, pages: c.pages }, ]), ), + // Only once there is something to say, so a backfill that never met + // the refusal keeps the shape it always had. + ...(unsupported.size > 0 + ? { edgeFilterUnsupported: [...unsupported] } + : {}), }, }, }); + if (unsupported !== tracker.edgeFilterUnsupported) { + for (const key of unsupported) tracker.edgeFilterUnsupported.add(key); + } // `next` may be the tracker's own collections: copy before clearing. const done = [...next.done]; const cursors = [...next.cursors]; @@ -561,6 +597,7 @@ export async function runWithGoogleHealthBackfillResume( done: Set, fn: () => Promise, cursors: Map = new Map(), + edgeFilterUnsupported: Set = new Set(), ): Promise> { const tracker: BackfillResumeTracker = { userId, @@ -569,6 +606,7 @@ export async function runWithGoogleHealthBackfillResume( skipped: [], advanced: new Map(), collections: [], + edgeFilterUnsupported, }; const result = await backfillResumeStorage.run(tracker, fn); return { @@ -581,11 +619,13 @@ export async function runWithGoogleHealthBackfillResume( /** * Where a resumable collection's walk starts in this backfill attempt: the - * edge an earlier attempt recorded (if any) and the pages it has already - * read. Null outside a backfill, where every walk reads its whole window. + * edge an earlier attempt recorded (if any), whether Google may be asked for + * it with a filter (false once Google refused that filter for the + * collection), and the pages it has already read. Null outside a backfill, + * where every walk reads its whole window. */ export function googleHealthBackfillCursor(key: GoogleHealthCollectionKey): { - edge?: { at: Date; order: "desc" | "asc" }; + edge?: { at: Date; order: "desc" | "asc"; filter: boolean }; pagesSoFar: number; } | null { const resume = backfillResumeStorage.getStore(); @@ -593,12 +633,47 @@ export function googleHealthBackfillCursor(key: GoogleHealthCollectionKey): { const cursor = resume.cursors.get(key); return cursor ? { - edge: { at: new Date(cursor.at), order: cursor.order }, + edge: { + at: new Date(cursor.at), + order: cursor.order, + filter: !resume.edgeFilterUnsupported.has(key), + }, pagesSoFar: cursor.pages, } : { pagesSoFar: 0 }; } +/** + * Note that Google refused the edge filter on a resumed request of this + * collection, so every later slice reads without it from the start instead + * of paying for the refusal again. Annotated as + * `googleHealth.backfill.edgeFilterUnsupported` (the collections noted so + * far) on the run that met the refusal. A note that cannot be written is a + * warning, not a failure: the next slice meets the refusal once more and + * falls back again. Outside a backfill this does nothing. + */ +export async function noteGoogleHealthEdgeFilterUnsupported( + key: GoogleHealthCollectionKey, +): Promise { + const resume = backfillResumeStorage.getStore(); + if (!resume || resume.edgeFilterUnsupported.has(key)) return; + const unsupported = new Set([...resume.edgeFilterUnsupported, key]); + annotate({ + meta: { "googleHealth.backfill.edgeFilterUnsupported": [...unsupported] }, + }); + try { + await persistBackfillProgress(resume, { + done: resume.done, + cursors: resume.cursors, + edgeFilterUnsupported: unsupported, + }); + } catch (err) { + getEvent()?.addWarning( + `google-health: backfill progress write failed for ${key}: ${err}`, + ); + } +} + /** * Record that a resumable collection wrote one more page, holding samples * from `first` to `last` in the order Google returned them. Moves the diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 2bcad8002..de2a0f945 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -54,6 +54,7 @@ import { googleHealthHardFailureCount, googleHealthTokenSource, handleCollectionFetchError, + noteGoogleHealthEdgeFilterUnsupported, recordGoogleHealthBackfillPage, runGoogleHealthCollection, upsertGoogleHealthMeasurements, @@ -277,9 +278,8 @@ async function syncMetricCollection( const cursor = isResumable(resource) ? googleHealthBackfillCursor(resource.verb) : null; - const maxPages = - (resource.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES) - - (cursor?.pagesSoFar ?? 0); + const ceiling = resource.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES; + const maxPages = ceiling - (cursor?.pagesSoFar ?? 0); if (cursor && maxPages <= 0) { getEvent()?.addWarning( `google-health ${resource.verb}: backfill reached its page ceiling of ${resource.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES} pages for ${userId}`, @@ -301,7 +301,20 @@ async function syncMetricCollection( ? (opts.intradayStart ?? opts.start) : opts.start, maxPages, - ...(cursor ? { resumable: true, edge: cursor.edge } : {}), + ...(cursor + ? { + resumable: true, + edge: cursor.edge && { + ...cursor.edge, + tz, + // Without the bound the walk passes over what earlier slices + // wrote, and they wrote no more than the collection's ceiling. + maxSkipPages: ceiling, + onFilterUnsupported: () => + noteGoogleHealthEdgeFilterUnsupported(resource.verb), + }, + } + : {}), }, async (points) => { const readings: GoogleHealthMeasurementUpsert[] = []; diff --git a/src/lib/jobs/__tests__/google-health-backfill.test.ts b/src/lib/jobs/__tests__/google-health-backfill.test.ts index 796893945..86123d304 100644 --- a/src/lib/jobs/__tests__/google-health-backfill.test.ts +++ b/src/lib/jobs/__tests__/google-health-backfill.test.ts @@ -61,6 +61,7 @@ vi.mock("@/lib/google-health/sync-core", () => ({ GOOGLE_HEALTH_INTEGRATION_KEY: "google-health", readGoogleHealthBackfillDone: () => new Set(resumeState.doneBefore), readGoogleHealthBackfillCursors: () => new Map(), + readGoogleHealthBackfillEdgeFilterUnsupported: () => new Set(), runWithGoogleHealthBackfillResume: async ( _userId: string, done: Set, @@ -316,6 +317,7 @@ describe("runGoogleHealthBackfillForUser — slices", () => { deadline: null, maxPages: 500, pages: 3, + skippedPages: 0, stopReason: "rate_limited", now: Date.now, })); diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index 05a3a27bd..b6bc711fe 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -23,6 +23,7 @@ import { import { readGoogleHealthBackfillCursors, readGoogleHealthBackfillDone, + readGoogleHealthBackfillEdgeFilterUnsupported, runWithGoogleHealthBackfillResume, } from "@/lib/google-health/sync-core"; import { @@ -185,6 +186,9 @@ export async function runGoogleHealthBackfillForUser( } const done = readGoogleHealthBackfillDone(connection?.backfillProgress); const cursors = readGoogleHealthBackfillCursors(connection?.backfillProgress); + const edgeFilterUnsupported = readGoogleHealthBackfillEdgeFilterUnsupported( + connection?.backfillProgress, + ); const doneBefore = done.size; const budget = googleHealthSliceBudget( @@ -211,6 +215,7 @@ export async function runGoogleHealthBackfillForUser( budget, ), cursors, + edgeFilterUnsupported, ); const finished = done.size - doneBefore; @@ -218,6 +223,9 @@ export async function runGoogleHealthBackfillForUser( slice, elapsedMs: Date.now() - startedAt, pages: budget.pages, + // Of those, the pages read only to pass over samples beyond an edge, + // where Google refused the edge filter. + skippedPages: budget.skippedPages, stopReason: budget.stopReason, imported, collectionsDone: done.size, From ebc0168f5321e22b762086b826f95d7eb5f3f71e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 12:33:34 +0200 Subject: [PATCH 13/40] test(google-health): pin the fallback when Google refuses the resume edge filter --- .../__tests__/edge-filter-fallback.test.ts | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 src/lib/google-health/__tests__/edge-filter-fallback.test.ts diff --git a/src/lib/google-health/__tests__/edge-filter-fallback.test.ts b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts new file mode 100644 index 000000000..caa2255f6 --- /dev/null +++ b/src/lib/google-health/__tests__/edge-filter-fallback.test.ts @@ -0,0 +1,421 @@ +/** + * A resumed backfill walk asks Google for the samples up to its edge with an + * upper bound (`sample_time.physical_time <= ""`). That bound is valid + * filter grammar but has not been seen working against the live API. If + * Google refuses it, every resumed slice would fail on its first request and + * the backfill would be stuck again. + * + * - A 400 on the first resumed request is answered by reading the + * collection again without the bound and passing over the samples beyond + * the edge here; the rows handed on are the ones the bound would have + * returned. + * - The refusal is noted per collection in the backfill's progress, and the + * next slice reads without the bound from the start. + * - A 400 that is about something else still fails the walk. + * - The pages passed over are not charged against the slice's budget, so a + * skip deeper than one slice's pages cannot keep a backfill from moving. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { safeFetchMock, prismaMock } = vi.hoisted(() => ({ + safeFetchMock: vi.fn(), + prismaMock: { googleHealthConnection: { update: vi.fn() } }, +})); +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, safeFetch: safeFetchMock }; +}); +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/logging/context", () => ({ + annotate: () => {}, + getEvent: () => null, +})); + +import { + GOOGLE_HEALTH_DATA_TYPES, + type GoogleHealthDataPoint, + forEachDataPointPage, + googleHealthSliceBudget, + refusesEdgeFilter, + runWithGoogleHealthClientOutcome, + runWithGoogleHealthStop, +} from "../client"; +import { GoogleHealthApiError } from "../response-classifier"; +import { + googleHealthBackfillCursor, + noteGoogleHealthEdgeFilterUnsupported, + readGoogleHealthBackfillCursors, + readGoogleHealthBackfillEdgeFilterUnsupported, + recordGoogleHealthBackfillPage, + runWithGoogleHealthBackfillResume, +} from "../sync-core"; + +const MINUTE = 60_000; +const NOW = Date.parse("2026-01-01T00:00:00Z"); +/** Reading `g` is `g` minutes old; the list runs newest first. */ +const TOTAL = 40; +const PAGE = 5; +const at = (g: number) => new Date(NOW - g * MINUTE); + +const server = { refuseUpperBound: false, requests: [] as string[] }; + +/** + * A heart-rate list built from the filter and the page token it is asked + * with, like the multi-year integration fixture. With `refuseUpperBound`, + * any filter carrying `<=` is refused the way Google refuses a filter. + */ +function respond(url: string): Response { + const u = new URL(url); + const filter = u.searchParams.get("filter") ?? ""; + server.requests.push(filter); + if (server.refuseUpperBound && filter.includes("<=")) { + return json( + { + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid filter expression.", + }, + }, + 400, + ); + } + const upTo = /physical_time <= "([^"]+)"/.exec(filter)?.[1]; + const newest = upTo + ? Math.max(0, Math.ceil((NOW - Date.parse(upTo)) / MINUTE)) + : 0; + const size = Number(u.searchParams.get("pageSize")); + const token = Number(u.searchParams.get("pageToken") ?? 0); + const start = newest + token * size; + const end = Math.min(TOTAL, start + size); + const dataPoints = []; + for (let g = start; g < end; g++) { + dataPoints.push({ + heartRate: { + beatsPerMinute: String(60 + g), + sampleTime: { physicalTime: at(g).toISOString() }, + }, + }); + } + return json({ + dataPoints, + ...(end < TOTAL ? { nextPageToken: String(token + 1) } : {}), + }); +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +type Query = Parameters[3]; + +/** Walk and collect what the walk hands on, page by page. */ +async function walk(query: Query) { + const pages: GoogleHealthDataPoint[][] = []; + const { outcome } = await runWithGoogleHealthClientOutcome(() => + forEachDataPointPage( + GOOGLE_HEALTH_DATA_TYPES.heartRate, + "token", + "fetchHeartRate", + { pageSize: PAGE, ...query }, + (points) => { + pages.push(points); + }, + ), + ); + return { pages, rows: pages.flat(), outcome }; +} + +beforeEach(() => { + safeFetchMock.mockReset(); + safeFetchMock.mockImplementation(async (url: string) => respond(url)); + prismaMock.googleHealthConnection.update.mockReset(); + prismaMock.googleHealthConnection.update.mockResolvedValue({}); + server.refuseUpperBound = false; + server.requests = []; +}); + +// The edge sits mid-page so the page that crosses it is split. +const EDGE = { at: at(17), order: "desc" as const }; + +describe("a resumed walk whose edge filter Google refuses", () => { + it("reads again without the bound and hands on exactly the rows the bound would have", async () => { + const filtered = await walk({ resumable: true, edge: EDGE }); + expect(server.requests[0]).toContain("<="); + expect(filtered.rows).toHaveLength(TOTAL - 17); + + server.requests = []; + server.refuseUpperBound = true; + const onFilterUnsupported = vi.fn(); + const fallback = await walk({ + resumable: true, + edge: { ...EDGE, onFilterUnsupported }, + }); + + expect(onFilterUnsupported).toHaveBeenCalledTimes(1); + // One refused request, then the whole walk without any filter. + expect(server.requests[0]).toContain("<="); + expect(server.requests.slice(1).every((f) => f === "")).toBe(true); + expect(fallback.rows).toEqual(filtered.rows); + expect(fallback.outcome.truncated).toBe(false); + }); + + it("reads without the bound from the start once the collection is known to refuse it", async () => { + const filtered = await walk({ resumable: true, edge: EDGE }); + server.requests = []; + server.refuseUpperBound = true; + const onFilterUnsupported = vi.fn(); + + const skipped = await walk({ + resumable: true, + edge: { ...EDGE, filter: false, onFilterUnsupported }, + }); + + expect(server.requests.some((f) => f.includes("<="))).toBe(false); + expect(onFilterUnsupported).not.toHaveBeenCalled(); + expect(skipped.rows).toEqual(filtered.rows); + }); + + it("follows a walk that runs oldest first", async () => { + // An ascending walk keeps the samples at or after its edge. + safeFetchMock.mockImplementation(async () => + json({ + dataPoints: [5, 4, 3, 2, 1, 0].map((g) => ({ + heartRate: { + beatsPerMinute: "60", + sampleTime: { physicalTime: at(g).toISOString() }, + }, + })), + }), + ); + const { rows } = await walk({ + resumable: true, + edge: { at: at(2), order: "asc", filter: false }, + }); + expect(rows).toHaveLength(3); + }); +}); + +describe("a 400 that is not about the edge filter", () => { + it("fails the walk when the first request names something else", async () => { + safeFetchMock.mockImplementation(async () => + json( + { + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid page token.", + }, + }, + 400, + ), + ); + const onFilterUnsupported = vi.fn(); + await expect( + walk({ resumable: true, edge: { ...EDGE, onFilterUnsupported } }), + ).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + expect(onFilterUnsupported).not.toHaveBeenCalled(); + }); + + it("fails the walk on a 400 past the first page, where the filter was already taken", async () => { + let n = 0; + safeFetchMock.mockImplementation(async (url: string) => + ++n === 2 + ? json({ error: { code: 400, status: "INVALID_ARGUMENT" } }, 400) + : respond(url), + ); + const onFilterUnsupported = vi.fn(); + await expect( + walk({ resumable: true, edge: { ...EDGE, onFilterUnsupported } }), + ).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(onFilterUnsupported).not.toHaveBeenCalled(); + }); + + it("fails a walk without an edge on a 400 as before", async () => { + safeFetchMock.mockImplementation(async () => + json({ error: { code: 400, status: "INVALID_ARGUMENT" } }, 400), + ); + await expect(walk({ resumable: true })).rejects.toBeInstanceOf( + GoogleHealthApiError, + ); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + }); + + it("is told apart by what Google says", () => { + expect(refusesEdgeFilter(undefined)).toBe(true); + expect(refusesEdgeFilter("INVALID_ARGUMENT: Invalid filter.")).toBe(true); + expect(refusesEdgeFilter("INVALID_ARGUMENT")).toBe(true); + expect(refusesEdgeFilter("FAILED_PRECONDITION: filter on field")).toBe( + true, + ); + expect(refusesEdgeFilter("INVALID_ARGUMENT: Invalid page token.")).toBe( + false, + ); + expect(refusesEdgeFilter("INVALID_ARGUMENT: page_size too large")).toBe( + false, + ); + expect(refusesEdgeFilter("FAILED_PRECONDITION: account not set up")).toBe( + false, + ); + }); +}); + +describe("passing over the samples beyond the edge inside a slice", () => { + it("is not charged against the slice's pages, so the slice still writes its share", async () => { + // The edge is three pages deep; the slice may read two. + const budget = googleHealthSliceBudget(60_000, 2); + const { pages, outcome } = await runWithGoogleHealthStop( + () => false, + () => + walk({ + resumable: true, + edge: { at: at(15), order: "desc", filter: false }, + }), + budget, + ); + expect(pages).toHaveLength(2); + expect(budget.skippedPages).toBe(3); + expect(budget.pages).toBe(5); + expect(budget.stopReason).toBe("page_budget"); + expect(outcome.truncated).toBe(true); + }); + + it("does not stop on the slice's clock while it is still passing over", async () => { + let clock = 0; + safeFetchMock.mockImplementation(async (url: string) => { + clock += 10_000; + return respond(url); + }); + // Each request takes ten seconds and the slice has fifteen, counted from + // when the collection started; passing over the first three pages takes + // forty. + const budget = googleHealthSliceBudget(15_000, 500, () => clock); + budget.deadline = 15_000; + const { pages } = await runWithGoogleHealthStop( + () => false, + () => + walk({ + resumable: true, + edge: { at: at(15), order: "desc", filter: false }, + }), + budget, + ); + // The crossing page and the two after it, inside the fifteen seconds the + // slice still had once the skip was given back. + expect(pages).toHaveLength(3); + expect(budget.deadline).toBe(55_000); + expect(budget.stopReason).toBe("time_budget"); + }); + + it("still stops on the job's own budget", async () => { + let asked = 0; + const { pages, outcome } = await runWithGoogleHealthStop( + () => ++asked > 2, + () => + walk({ + resumable: true, + edge: { at: at(30), order: "desc", filter: false }, + }), + googleHealthSliceBudget(60_000, 500), + ); + expect(pages).toHaveLength(0); + expect(outcome.truncated).toBe(true); + }); + + it("gives up past its own ceiling instead of reading without end", async () => { + const { pages, outcome } = await walk({ + resumable: true, + edge: { at: at(30), order: "desc", filter: false, maxSkipPages: 2 }, + }); + expect(pages).toHaveLength(0); + expect(safeFetchMock).toHaveBeenCalledTimes(2); + expect(outcome.truncated).toBe(true); + }); +}); + +describe("the note that a collection refuses the edge filter", () => { + /** The progress the last write left in the column. */ + const lastProgress = () => + prismaMock.googleHealthConnection.update.mock.calls.at(-1)![0].data + .backfillProgress as unknown; + + it("is written once Google refuses, kept by later writes, and honoured by the next slice", async () => { + server.refuseUpperBound = true; + const cursors = new Map([ + [ + "fetchHeartRate", + { at: EDGE.at.toISOString(), order: "desc" as const, pages: 4 }, + ], + ]); + + // Slice one: resumes with the bound, is refused, falls back, writes a page. + await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + async () => { + const cursor = googleHealthBackfillCursor("fetchHeartRate")!; + expect(cursor.edge?.filter).toBe(true); + await walk({ + resumable: true, + maxPages: 1, + edge: { + ...cursor.edge!, + onFilterUnsupported: () => + noteGoogleHealthEdgeFilterUnsupported("fetchHeartRate"), + }, + }); + await recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at(17), + last: at(19), + }); + }, + new Map(cursors), + ); + expect(server.requests.filter((f) => f.includes("<="))).toHaveLength(1); + const progress = lastProgress(); + expect(progress).toMatchObject({ + edgeFilterUnsupported: ["fetchHeartRate"], + cursors: { fetchHeartRate: { at: at(19).toISOString() } }, + }); + + // Slice two: starts from what slice one left on disk. + server.requests = []; + const unsupported = readGoogleHealthBackfillEdgeFilterUnsupported(progress); + await runWithGoogleHealthBackfillResume( + "u1", + new Set(), + async () => { + const cursor = googleHealthBackfillCursor("fetchHeartRate")!; + expect(cursor.edge?.filter).toBe(false); + const { rows } = await walk({ resumable: true, edge: cursor.edge }); + expect(rows).toHaveLength(TOTAL - 19); + }, + readGoogleHealthBackfillCursors(progress), + unsupported, + ); + expect(server.requests.some((f) => f.includes("<="))).toBe(false); + }); + + it("leaves the progress in its old shape for a backfill that never met the refusal", async () => { + await runWithGoogleHealthBackfillResume("u1", new Set(), async () => { + await recordGoogleHealthBackfillPage("fetchHeartRate", { + first: at(0), + last: at(4), + }); + }); + expect(lastProgress()).not.toHaveProperty("edgeFilterUnsupported"); + }); + + it("reads only well-formed keys", () => { + expect([ + ...readGoogleHealthBackfillEdgeFilterUnsupported({ + edgeFilterUnsupported: ["fetchHeartRate", 3, "x".repeat(65)], + }), + ]).toEqual(["fetchHeartRate"]); + expect(readGoogleHealthBackfillEdgeFilterUnsupported(null).size).toBe(0); + }); +}); From 1f9180fe6fcb801a614d1fc51e0028a9a4a83db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 12:35:49 +0200 Subject: [PATCH 14/40] test(google-health): drive a backfill to completion against a Google that refuses the edge filter Also documents the fallback and its log fields. --- docs/integrations/google-health.md | 23 ++- src/lib/google-health/sync-core.ts | 3 +- .../google-health-backfill-multi-year.test.ts | 146 +++++++++++++++++- 3 files changed, 166 insertions(+), 6 deletions(-) diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index b9bf5e095..f1fbe715f 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -137,6 +137,19 @@ time of the oldest reading it has written, so the next slice asks Google only for what is older. A restart or an update in the middle loses at most the page that was being read. +Asking for "what is older" uses an upper bound on the sample time, which +Google's filter grammar allows but which a given account may still refuse. If +Google answers that first resumed request with `400`, the slice reads the data +type again without the bound, passes over the readings newer than its note +without writing them, and carries on writing from there. The refusal is noted +per data type, so later slices read without the bound straight away. The pages +passed over are not counted against the slice's five minutes or 500 pages: +otherwise a history deeper than one slice would spend every slice passing over +what earlier slices wrote and never write anything new. Only the job's own +time limit stops a slice while it is passing over, so on such an account each +slice takes longer the further back the backfill has got. A `400` that names +something else (the page token, the page size) is still a failure. + The hourly sync waits for the slice that is running (up to eight minutes) instead of skipping the account, so recent data keeps arriving while an old history is still coming in. @@ -172,12 +185,15 @@ reading: `status` the HTTP status, `duration_ms` how long it took. A request that was repeated carries a `note` (`rate_limited_retrying_in_ms=4000`, `server_error_retrying_in_ms=2000`, `no_response_retrying_in_ms=2000`, - `token_rejected_retrying_with_fresh_token`); one that failed for good + `token_rejected_retrying_with_fresh_token`, + `edge_filter_rejected_reading_without_it`); one that failed for good carries an `error`. Successful requests are listed up to 60 per line and everything up to 200; `external_calls_omitted` counts the rest. - `meta["googleHealth.backfill.slice"]` sums the slice up: - `pages`, `elapsedMs` and `imported`: what the slice read, how long it - took, how many readings it wrote. + took, how many readings it wrote. `skippedPages` is the part of `pages` + read only to pass over readings already written, on a data type whose + upper-bound filter Google refused. - `stopReason`: why it ended early. `time_budget` and `page_budget` are the normal end of a slice, `rate_limited` means Google would not take more requests for now, `job_budget` that the job itself ran out of time. @@ -191,6 +207,9 @@ reading: `edgeFrom` and `edgeTo`, the oldest reading written before and after the slice. - `edges`: where every unfinished page-by-page data type stands now. +- `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice + where Google refused the upper-bound filter, listing the data types noted + as refusing it. - `action.name` is `google_health.backfill.continued` for a slice that made progress and queued the next one, `google_health.backfill.complete` for the one that finished the history, and `google_health.backfill.stuck` for one diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index aea8d7d19..048e3bd55 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -545,7 +545,8 @@ async function persistBackfillProgress( edgeFilterUnsupported?: Set; }, ): Promise { - const unsupported = next.edgeFilterUnsupported ?? tracker.edgeFilterUnsupported; + const unsupported = + next.edgeFilterUnsupported ?? tracker.edgeFilterUnsupported; await prisma.googleHealthConnection.update({ where: { userId: tracker.userId }, data: { diff --git a/tests/integration/google-health-backfill-multi-year.test.ts b/tests/integration/google-health-backfill-multi-year.test.ts index bf4f63995..c501bc210 100644 --- a/tests/integration/google-health-backfill-multi-year.test.ts +++ b/tests/integration/google-health-backfill-multi-year.test.ts @@ -23,7 +23,11 @@ * it) instead of being skipped for as long as the import runs; * - every slice writes a log event naming its requests and where it got to; * - the heap does not grow with the history (run it under the production - * ceiling: `NODE_OPTIONS=--max-old-space-size=524`). + * ceiling: `NODE_OPTIONS=--max-old-space-size=524`); + * - and, in a second run over two years, a Google that refuses the resume + * edge's upper bound (`<=`): the backfill falls back to reading without + * it, notes the refusal so it is met only once, and still imports every + * reading exactly once. * * Google is replaced at the `safeFetch` seam by a generator that builds each * page on request from the filter and page token it is asked with, so the @@ -68,6 +72,11 @@ const fake = vi.hoisted(() => ({ /** Faults to inject, keyed by the backfill request number they hit. */ faults: new Map(), onRequest: null as (() => void) | null, + /** Refuse any filter with an upper bound, and count the refusals. */ + refuseUpperBound: false, + refused: 0, + /** Backfill requests that carried an upper bound. */ + upperBoundRequests: 0, })); vi.mock("@/lib/safe-fetch", async (importOriginal) => { @@ -93,6 +102,22 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { const filter = u.searchParams.get("filter") ?? ""; const fromBackfill = !filter.includes(">="); if (fromBackfill) fake.backfillRequests += 1; + if (filter.includes("<=")) { + fake.upperBoundRequests += 1; + if (fake.refuseUpperBound) { + fake.refused += 1; + return respond( + { + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid filter expression.", + }, + }, + 400, + ); + } + } const fault = fromBackfill ? fake.faults.get(fake.backfillRequests) : undefined; @@ -197,7 +222,8 @@ function report(line: string): void { process.stderr.write(`[gh-multi-year] ${line}\n`); } -beforeAll(async () => { +/** A fresh account with `points` minutes of heart rate behind it. */ +async function seed(points: number): Promise { const prisma = getPrismaClient(); await truncateAllTables(prisma); const { encrypt } = await import("@/lib/crypto"); @@ -214,7 +240,19 @@ beforeAll(async () => { }, }); fake.now = NOW; - fake.total = POINTS; + fake.total = points; + fake.heartRateRequests = 0; + fake.backfillRequests = 0; + fake.servedPoints = 0; + fake.faults.clear(); + fake.onRequest = null; + fake.refuseUpperBound = false; + fake.refused = 0; + fake.upperBoundRequests = 0; +} + +beforeAll(async () => { + await seed(POINTS); }, 120_000); afterAll(async () => { @@ -393,4 +431,106 @@ describe("Google Health backfill over three years of heart rate (#1220)", () => expect(samples).toBeGreaterThan(5); expect(growth).toBeLessThan(GROWTH_BUDGET_BYTES); }, 3_600_000); + + it("completes when Google refuses the resume edge's upper bound, meeting the refusal once", async () => { + // Two years: the first slice reads without an edge, the second resumes + // with the bound and is refused, the third reads without it from the start. + const points = 2 * 365 * 24 * 60; + await seed(points); + fake.refuseUpperBound = true; + + const { drainIntegrationBackfillAdmission } = + await import("@/lib/jobs/integration-backfill-drain"); + const { handleGoogleHealthSync } = + await import("@/lib/jobs/reminder/google-health-sync"); + const { GOOGLE_HEALTH_TRANSIENT_RETRY } = + await import("@/lib/google-health/client"); + GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0; + + const prisma = getPrismaClient(); + let payload: AdmissionPayload | null = { + kind: "google-health-backfill", + data: { userId: USER, enqueuedAt: new Date().toISOString() }, + }; + const slices: Array<{ + imported: number; + continued: number; + skippedPages: number; + refusalNoted: unknown; + }> = []; + + for (let n = 0; payload && n < 40; n++) { + sent.jobs = []; + logged.events = []; + const outcome = await drainIntegrationBackfillAdmission([ + admissionJob(payload, n), + ]); + expect(outcome.ok).toBe(true); + const did = (outcome as { did: Record }).did; + const runEvents = logged.events.filter( + (e) => e.background?.task_name === "job.integration_backfill", + ); + expect(runEvents).toHaveLength(1); + const meta = runEvents[0]!.meta ?? {}; + const sliceMeta = meta["googleHealth.backfill.slice"] as { + pages: number; + skippedPages: number; + stopReason: string | null; + }; + slices.push({ + imported: did.imported, + continued: did.continued, + skippedPages: sliceMeta.skippedPages, + refusalNoted: meta["googleHealth.backfill.edgeFilterUnsupported"], + }); + report( + `refusing slice ${payload.data.slice ?? 0}: pages=${sliceMeta.pages} ` + + `skipped=${sliceMeta.skippedPages} stop=${sliceMeta.stopReason} ` + + `imported=${did.imported}`, + ); + payload = nextSlice(); + if (payload) { + const hourly = await handleGoogleHealthSync([] as never); + expect(hourly).toMatchObject({ + ok: true, + did: { users_skipped: 0, users_failed: 0, users_parked: 0 }, + }); + } + } + + const connection = await prisma.googleHealthConnection.findUniqueOrThrow({ + where: { userId: USER }, + select: { backfillCompletedAt: true, backfillProgress: true }, + }); + expect(connection.backfillCompletedAt).toBeInstanceOf(Date); + expect(connection.backfillProgress).toBeNull(); + expect(payload).toBeNull(); + expect(slices.slice(0, -1).every((s) => s.continued === 1)).toBe(true); + + // The bound was asked for once, refused once, and never again: the + // refusal was noted on the slice that met it and honoured after. + expect(fake.upperBoundRequests).toBe(1); + expect(fake.refused).toBe(1); + expect(slices.filter((s) => s.refusalNoted !== undefined)).toEqual([ + expect.objectContaining({ refusalNoted: ["fetchHeartRate"] }), + ]); + // More than one slice had to pass over what the ones before it wrote, + // and each still wrote its share. + const skipping = slices.filter((s) => s.skippedPages > 0); + expect(skipping.length).toBeGreaterThan(1); + expect(skipping.every((s) => s.imported > 0)).toBe(true); + + // Every reading exactly once, none of them rewritten. + const rows = await prisma.measurement.count({ + where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" }, + }); + expect(rows).toBe(points); + expect(slices.reduce((sum, s) => sum + s.imported, 0)).toBe(points); + const versions = await prisma.measurement.aggregate({ + where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" }, + _max: { syncVersion: true }, + _min: { syncVersion: true }, + }); + expect(versions._max.syncVersion).toBe(versions._min.syncVersion); + }, 3_600_000); }); From 8ebc6fe5464d0a80bccee30108fb436cf2a0a598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 14:30:55 +0200 Subject: [PATCH 15/40] fix(integrations): give Oura and WHOOP a 48 h silence window Their rings and straps upload only when the phone app is opened, so a day without opening it is ordinary and must not page the operator. Wearables whose cloud receives data on its own keep 24 h, Apple Health keeps 48 h, and scales, cuffs and Strava never alert on silence. --- .../__tests__/watchdog-classify.test.ts | 63 ++++++++++++++++--- src/lib/integrations/watchdog-classify.ts | 27 +++++--- .../integration/integration-watchdog.test.ts | 9 +-- 3 files changed, 79 insertions(+), 20 deletions(-) diff --git a/src/lib/integrations/__tests__/watchdog-classify.test.ts b/src/lib/integrations/__tests__/watchdog-classify.test.ts index 0e2992e46..9e9be7b9a 100644 --- a/src/lib/integrations/__tests__/watchdog-classify.test.ts +++ b/src/lib/integrations/__tests__/watchdog-classify.test.ts @@ -47,7 +47,8 @@ const openRow = (overrides: Partial = {}): WatchRow => ({ }); describe("classifySilence", () => { - const oura = SOURCE_PROFILES.oura; + // A wearable whose cloud receives data on its own: the 24 h window. + const wearable = SOURCE_PROFILES.fitbit; const withings = SOURCE_PROFILES.withings; it("never flags an on-demand source for silence, however long and however regular", () => { @@ -67,7 +68,7 @@ describe("classifySilence", () => { it("is clear while a continuous source delivers inside its window", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(20), deliveredDays: null, currentSilentSince: null, @@ -79,7 +80,7 @@ describe("classifySilence", () => { it("asks for the history before flagging (the cheap hourly pass reads it lazily)", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(30), deliveredDays: null, currentSilentSince: null, @@ -91,7 +92,7 @@ describe("classifySilence", () => { it("flags a silent source that delivered on most of the previous 14 days", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(30), deliveredDays: NORMAL_DELIVERY_MIN_DAYS, currentSilentSince: null, @@ -103,7 +104,7 @@ describe("classifySilence", () => { it("does not flag a source its owner wears only now and then", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(30), deliveredDays: NORMAL_DELIVERY_MIN_DAYS - 1, currentSilentSince: null, @@ -115,7 +116,7 @@ describe("classifySilence", () => { it("keeps a standing flag on the hourly pass without re-reading history", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(50), deliveredDays: null, currentSilentSince: ago(50), @@ -127,7 +128,7 @@ describe("classifySilence", () => { it("drops a standing flag on the daily re-check once the history no longer says it delivers", () => { expect( classifySilence({ - profile: oura, + profile: wearable, lastDataAt: ago(24 * 6), deliveredDays: 8, currentSilentSince: ago(24 * 6), @@ -136,7 +137,55 @@ describe("classifySilence", () => { ).toEqual({ kind: "not_expected" }); }); + it("waits 48 h for Oura and WHOOP, which upload only when their phone app is opened", () => { + for (const key of ["oura", "whoop"] as const) { + const profile = SOURCE_PROFILES[key]; + expect(profile.kind).toBe("continuous"); + // A day and a half without opening the app is ordinary. + expect( + classifySilence({ + profile, + lastDataAt: ago(36), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "clear" }); + // Past two days it is not. + expect( + classifySilence({ + profile, + lastDataAt: ago(49), + deliveredDays: 14, + currentSilentSince: null, + now: NOW, + }), + ).toEqual({ kind: "flag", silentSince: ago(49) }); + } + }); + + it("keeps the 24 h window for wearables whose cloud receives data on its own", () => { + for (const key of [ + "fitbit", + "google-health", + "polar", + "nightscout", + ] as const) { + expect(SOURCE_PROFILES[key]).toEqual({ + kind: "continuous", + silenceWindowMs: 24 * HOUR, + }); + } + }); + + it("never alerts on silence for scales, cuffs and workout logs", () => { + for (const key of ["withings", "strava"] as const) { + expect(SOURCE_PROFILES[key].kind).toBe("on_demand"); + } + }); + it("gives Apple Health a wider window than a wearable", () => { + expect(SOURCE_PROFILES["apple-health"].silenceWindowMs).toBe(48 * HOUR); const apple = SOURCE_PROFILES["apple-health"]; expect( classifySilence({ diff --git a/src/lib/integrations/watchdog-classify.ts b/src/lib/integrations/watchdog-classify.ts index cc1951e36..0e0ab77f8 100644 --- a/src/lib/integrations/watchdog-classify.ts +++ b/src/lib/integrations/watchdog-classify.ts @@ -50,20 +50,29 @@ export const INCIDENT_AFTER_MS = DAY_MS; /** * Per connection. Withings is mostly scales and cuffs, Strava is workouts: - * both are on-demand. Everything worn or attached around the clock is - * continuous. + * both are on-demand and never alert on silence, only on errors. Everything + * worn or attached around the clock is continuous. * - * Apple Health gets 48 h instead of 24 h. iOS defers background delivery when - * the phone is low on storage, in low-power mode or simply not unlocked, and a - * day of that is ordinary; the history gate already limits the check to - * phones that deliver almost daily, and the longer window keeps an ordinary - * quiet day from paging anyone. + * The window is how long a continuous source may stay quiet before silence + * counts, and it follows how each source actually delivers: + * + * - 24 h for wearables and CGMs whose cloud receives data on its own + * (Fitbit, Google Health, Polar, Nightscout). + * - 48 h for Oura and WHOOP. Their rings and straps upload only when the + * phone app is opened, so a day without opening it is ordinary and must + * not page anyone. + * - 48 h for Apple Health. iOS defers background delivery when the phone is + * low on storage, in low-power mode or simply not unlocked, and a day of + * that is ordinary too. + * + * The history gate already limits the check to sources that deliver almost + * daily; the window only decides how long a gap is ordinary. */ export const SOURCE_PROFILES: Record = { withings: { kind: "on_demand", silenceWindowMs: DAY_MS }, strava: { kind: "on_demand", silenceWindowMs: DAY_MS }, - whoop: { kind: "continuous", silenceWindowMs: DAY_MS }, - oura: { kind: "continuous", silenceWindowMs: DAY_MS }, + whoop: { kind: "continuous", silenceWindowMs: 2 * DAY_MS }, + oura: { kind: "continuous", silenceWindowMs: 2 * DAY_MS }, fitbit: { kind: "continuous", silenceWindowMs: DAY_MS }, "google-health": { kind: "continuous", silenceWindowMs: DAY_MS }, polar: { kind: "continuous", silenceWindowMs: DAY_MS }, diff --git a/tests/integration/integration-watchdog.test.ts b/tests/integration/integration-watchdog.test.ts index 81c5ac97d..44de37b40 100644 --- a/tests/integration/integration-watchdog.test.ts +++ b/tests/integration/integration-watchdog.test.ts @@ -96,12 +96,13 @@ beforeEach(async () => { describe("integration watchdog", () => { it("flags a normally-daily wearable gone silent, alerts once, then sends one recovery note", async () => { - // Fourteen consecutive days of data before the window, newest 30 h ago. + // Thirteen consecutive days of data before the window, newest 54 h ago: + // past Oura's 48 h window (it uploads only when its phone app is opened). await seedDailyReadings( USER, "OURA", - Array.from({ length: 13 }, (_, i) => i + 2), - 30, + Array.from({ length: 13 }, (_, i) => i + 3), + 54, ); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(NOW); @@ -170,7 +171,7 @@ describe("integration watchdog", () => { }); it("leaves a wearable alone when its owner wears it only now and then", async () => { - await seedDailyReadings(USER, "OURA", [3, 7, 11], 30); + await seedDailyReadings(USER, "OURA", [3, 7, 11], 54); await recordSyncSuccess(USER, "oura"); const summary = await runIntegrationWatchdog({ mode: "hourly", now: NOW }); expect(summary.silencesFlagged).toBe(0); From 600145427b4410bf3e89ea192817004a89f04a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 14:58:15 +0200 Subject: [PATCH 16/40] docs(measurements): describe the 10-minute heart-rate bucket in the batch route header --- src/app/api/measurements/batch/route.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index fa0c55400..4b42d4efb 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -24,10 +24,12 @@ * · per-day cumulative totals — `stats::` (Steps, * Active Energy, Sleep Duration, Walking/Running Distance, * Flights Climbed); - * · v1.19.0 (iOS #34) hourly heart-rate buckets — - * `stats:HKQuantityTypeIdentifierHeartRate:` - * carrying the hour's AVERAGE bpm as one PULSE row, so iOS stops - * uploading one row per raw HR sample. + * · v1.19.0 (iOS #34) heart-rate buckets, 10 minutes wide since + * v1.30.7 — `stats:HKQuantityTypeIdentifierHeartRate:` + * where `` is the ISO-8601 UTC 10-minute boundary + * (minutes 00/10/20/30/40/50), carrying the bucket's AVERAGE bpm + * as one PULSE row, so iOS stops uploading one row per raw HR + * sample. * Both surface as `updated` (the row's value is overwritten) rather * than `duplicate` (the new value dropped). Sample-class externalIds * (every other prefix) keep the strict immutable `duplicate` @@ -153,11 +155,11 @@ const batchEntrySchema = z.object({ // device's own verdict / severity; resolved to a `rhythmClassification` // by `mapAppleHealthEntry`. Ignored for every non-event identifier. categoryValue: z.number().int().min(0).max(20).optional(), - // v1.19.2 (iOS #34 extension) — per-bucket spread for the hourly - // heart-rate bucket. `value` carries the hour's AVERAGE bpm; these carry - // the hour's MIN / MAX so a client renders the intra-hour band without + // v1.19.2 (iOS #34 extension) — per-bucket spread for the 10-minute + // heart-rate bucket. `value` carries the bucket's AVERAGE bpm; these carry + // its MIN / MAX so a client renders the band inside the bucket without // re-uploading raw samples. Persisted ONLY on a well-formed - // `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored + // `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored // NULL) for every other entry. Optional + backward-compatible: a pre-W192 // iOS build omits them and the bucket keeps the avg-only contract. valueMin: z.number().finite().optional(), From 53e60f43f12cad4943ee616df88f260f4e47c7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:03:06 +0200 Subject: [PATCH 17/40] feat(labs): list every marker from a server-side per-biomarker overview --- docs/api/openapi.yaml | 74 +++++ src/app/api/labs/overview/route.ts | 36 +++ .../labs/__tests__/lab-list-columns.test.tsx | 39 ++- src/components/labs/lab-list.tsx | 117 +++----- src/components/labs/types.ts | 15 + src/lib/labs/overview.ts | 131 +++++++++ src/lib/openapi/routes/labs.ts | 45 +++ src/lib/query-keys/labs.ts | 5 + src/lib/validations/labs.ts | 7 + .../labs-overview-long-history.test.ts | 276 ++++++++++++++++++ 10 files changed, 650 insertions(+), 95 deletions(-) create mode 100644 src/app/api/labs/overview/route.ts create mode 100644 src/lib/labs/overview.ts create mode 100644 tests/integration/labs-overview-long-history.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 17fd03bc0..f48efd348 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14924,6 +14924,26 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a12 + /api/labs/overview: + get: + tags: + - Labs + summary: Per-marker overview of the caller's lab history + description: "One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading + count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut + off by a page size." + parameters: [] + responses: + "200": + description: Lab overview. + content: + application/json: + schema: + $ref: "#/components/schemas/LabOverviewEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/labs/{id}: get: tags: @@ -67584,6 +67604,60 @@ components: - data - error additionalProperties: false + LabOverviewEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LabOverviewResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LabOverviewResponse: + type: object + properties: + markers: + type: array + items: + $ref: "#/components/schemas/LabOverviewMarker" + required: + - markers + additionalProperties: false + LabOverviewMarker: + type: object + properties: + key: + type: string + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + trend: + type: array + items: + anyOf: + - type: number + - type: "null" + latest: + $ref: "#/components/schemas/LabResult" + required: + - key + - count + - trend + - latest + additionalProperties: false + description: One biomarker in the caller's lab history. `key` is `bm:` for a catalog-linked marker or + `analyte:` for a legacy unlinked one. `count` is every live reading of the marker. `trend` + holds the values of its newest readings (at most 50), oldest first, null for a qualitative reading. `latest` is + the newest reading, serialised exactly like a `GET /api/labs` row. GetLabResultResponse: type: object properties: diff --git a/src/app/api/labs/overview/route.ts b/src/app/api/labs/overview/route.ts new file mode 100644 index 000000000..c25a41ec5 --- /dev/null +++ b/src/app/api/labs/overview/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { loadLabOverview } from "@/lib/labs/overview"; +import { annotate } from "@/lib/logging/context"; +import { labOverviewQuerySchema } from "@/lib/validations/labs"; + +/** + * `GET /api/labs/overview` — one entry per biomarker over the caller's whole + * live lab history: the newest reading, the reading count and a short trend. + * The Labs list renders from this instead of grouping a capped page of raw + * rows, so a marker measured only long ago still has its row. Same read gate + * as `GET /api/labs` (`requireRecordAuth("read", "labs")`). + */ +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "labs"); + + const parsed = labOverviewQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + annotate({ + action: { name: "labs.overview.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const markers = await loadLabOverview(user.id); + annotate({ + action: { name: "labs.overview" }, + meta: { markers: markers.length }, + }); + return apiSuccess({ markers }); +}); diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 44c7505b6..4b570c544 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import type { LabResultDto, LabResultListResponse } from "../types"; +import type { LabOverviewResponse, LabResultDto } from "../types"; /** * Every row of the compact list is five grid cells from `lg` up. The range @@ -55,22 +55,29 @@ function renderList(results: LabResultDto[]) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); - const data: LabResultListResponse = { - results, - meta: { total: results.length, limit: 500, offset: 0 }, - }; - queryClient.setQueryData( - queryKeys.labResultsList({ - biomarkerId: undefined, - analyte: undefined, - panel: undefined, - from: undefined, - to: undefined, - page: 0, - sortDir: "desc", + // The overview the server would send for these readings: one marker per + // biomarker (or lower-cased analyte), newest reading as `latest`. + const byKey = new Map(); + for (const r of results) { + const key = r.biomarkerId + ? `bm:${r.biomarkerId}` + : `analyte:${r.analyte.toLowerCase()}`; + byKey.set(key, [...(byKey.get(key) ?? []), r]); + } + const data: LabOverviewResponse = { + markers: [...byKey.entries()].map(([key, readings]) => { + const ordered = [...readings].sort( + (a, b) => Date.parse(a.takenAt) - Date.parse(b.takenAt), + ); + return { + key, + count: ordered.length, + trend: ordered.map((r) => r.value), + latest: ordered[ordered.length - 1], + }; }), - data, - ); + }; + queryClient.setQueryData(queryKeys.labResultsOverview(), data); return renderToStaticMarkup( diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 77d7e72cc..e98e04ffa 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -26,57 +26,44 @@ import { useLabDate, useLabNumber } from "./use-lab-format"; import { LabReferenceRangeBar } from "./lab-reference-range-bar"; import { ReferenceRangeBadge } from "./reference-range-badge"; import { SourceRangeNote } from "./source-range-note"; -import type { LabResultDto, LabResultListResponse } from "./types"; +import type { + LabOverviewMarkerDto, + LabOverviewResponse, + LabResultDto, +} from "./types"; interface MarkerGroup { - /** Stable group key: the biomarker id, or `analyte:` for legacy rows. */ + /** Stable group key: `bm:`, or `analyte:` for legacy rows. */ key: string; /** Link target when the group is catalog-linked; null for legacy rows. */ biomarkerId: string | null; analyte: string; panel: string | null; unit: string; - readings: LabResultDto[]; + /** Every live reading of the marker, counted by the server. */ + count: number; + /** Values of the newest readings, oldest first, for the sparkline. */ + trend: (number | null)[]; latest: LabResultDto; } /** - * Group readings by their linked biomarker (or, for legacy un-linked rows, - * case-insensitively by analyte). A catalog-linked group deep-links to its - * detail chart; legacy groups render inert until the backfill links them. + * One group per marker, as the server resolved it over the whole history. A + * catalog-linked group deep-links to its detail chart; legacy groups render + * inert until the backfill links them. The server already orders by the + * newest reading. */ -function groupReadings(results: LabResultDto[]): MarkerGroup[] { - const byKey = new Map(); - for (const r of results) { - const key = r.biomarkerId - ? `bm:${r.biomarkerId}` - : `analyte:${r.analyte.toLowerCase()}`; - const bucket = byKey.get(key); - if (bucket) bucket.push(r); - else byKey.set(key, [r]); - } - - const groups: MarkerGroup[] = []; - for (const [key, readings] of byKey.entries()) { - const ordered = [...readings].sort( - (a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(), - ); - const latest = ordered[ordered.length - 1]; - groups.push({ - key, - biomarkerId: latest.biomarkerId, - analyte: latest.analyte, - panel: latest.panel, - unit: latest.unit, - readings: ordered, - latest, - }); - } - return groups.sort( - (a, b) => - new Date(b.latest.takenAt).getTime() - - new Date(a.latest.takenAt).getTime(), - ); +function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] { + return markers.map((marker) => ({ + key: marker.key, + biomarkerId: marker.latest.biomarkerId, + analyte: marker.latest.analyte, + panel: marker.latest.panel, + unit: marker.latest.unit, + count: marker.count, + trend: marker.trend, + latest: marker.latest, + })); } /** @@ -157,26 +144,17 @@ export function LabList({ // (no catalog match) carry no subtitle rather than a fabricated one. // v1.24 — the per-marker description moved to the biomarker detail page // (beneath the heading); the overview rows no longer carry it. - const listKey = queryKeys.labResultsList({ - biomarkerId: undefined, - analyte: undefined, - panel: undefined, - from: undefined, - to: undefined, - page: 0, - sortDir: "desc", - }); - + // One entry per biomarker over the whole history, grouped server-side: a + // capped page of raw rows dropped every marker measured only long ago. const { data, isLoading, isError, refetch } = useQuery({ - queryKey: listKey, - queryFn: () => - apiGet("/api/labs?limit=500&sortDir=desc"), + queryKey: queryKeys.labResultsOverview(), + queryFn: () => apiGet("/api/labs/overview"), }); const groups = useMemo(() => { - const base = groupReadings(data?.results ?? []); - // v1.18.6 (MOD-04) — honour the user's Labs sort choice. `groupReadings` - // already returns most-recent-first; `recentAsc` reverses, the alpha + const base = toGroups(data?.markers ?? []); + // v1.18.6 (MOD-04) — honour the user's Labs sort choice. The server + // order is most-recent-first; `recentAsc` reverses, the alpha // options sort by analyte name (#43), and `manual` applies the persisted // biomarker order (legacy un-linked groups, which carry no biomarkerId, // sort after the ordered block). @@ -195,14 +173,7 @@ export function LabList({ return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key); } return base; - }, [data?.results, prefs.sortDir, prefs.order]); - - // The list caps at 500 rows server-side (the `limit` ceiling). Surface a - // calm "showing latest N of M" hint when the cap truncates so the count is - // never silently wrong — proper cursor paging is a later iteration. - const total = data?.meta?.total ?? 0; - const shown = data?.results?.length ?? 0; - const truncated = total > shown; + }, [data?.markers, prefs.sortDir, prefs.order]); const listEmpty = !isLoading && !isError && groups.length === 0; useEffect(() => { @@ -266,11 +237,6 @@ export function LabList({ // branch above carries `lab-list-loading` instead, so the marker // cannot stand for a silhouette.
- {truncated ? ( -

- {t("labs.showingLatestOf", { shown, total })} -

- ) : null} {/* `@container` makes the columns follow the width the list has, not the window's: below `@5xl` (64rem of card) the row is the `lg` table it always was (the two tiers exclude each other, so the result does not @@ -319,7 +285,7 @@ export function LabList({
reading.value)} + values={group.trend} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} /> @@ -343,7 +309,7 @@ export function LabList({
reading.value)} + values={group.trend} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} /> @@ -380,13 +346,6 @@ export function LabList({ groups.length > 1 && "sm:grid-cols-2", )} > - {truncated ? ( -
  • -

    - {t("labs.showingLatestOf", { shown, total })} -

    -
  • - ) : null} {groups.map((group) => { return (
  • @@ -432,10 +391,10 @@ export function LabList({ {labDate(group.latest.takenAt)} - {group.readings.length > 1 ? ( + {group.count > 1 ? ( {t("labs.readingsCount", { - count: group.readings.length, + count: group.count, })} ) : null} @@ -447,7 +406,7 @@ export function LabList({ />
  • reading.value)} + values={group.trend} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} /> diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 90e7f60fb..512038fc1 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -65,6 +65,21 @@ export interface LabResultListResponse { meta: { total: number; limit: number; offset: number }; } +/** One biomarker in `GET /api/labs/overview`. */ +export interface LabOverviewMarkerDto { + /** `bm:`, or `analyte:` for a legacy row. */ + key: string; + /** Every live reading of the marker. */ + count: number; + /** Values of the newest readings, oldest first; null for a qualitative one. */ + trend: (number | null)[]; + latest: LabResultDto; +} + +export interface LabOverviewResponse { + markers: LabOverviewMarkerDto[]; +} + /** A user-scoped Biomarker catalog entry as the API serialises it. */ export interface BiomarkerDto { id: string; diff --git a/src/lib/labs/overview.ts b/src/lib/labs/overview.ts new file mode 100644 index 000000000..cd89e02dc --- /dev/null +++ b/src/lib/labs/overview.ts @@ -0,0 +1,131 @@ +/** + * Per-marker overview of an account's lab history (`GET /api/labs/overview`). + * + * The Labs list shows one row per biomarker: its newest reading, how many + * readings it has, and a small trend. It used to build that by fetching the + * newest 500 raw rows and grouping them in the browser, so on a long history + * every marker whose readings all lay beyond the 500th row vanished from the + * list without a word. The grouping now happens here, over every live row, so + * the answer does not depend on how many readings the other markers have. + * + * Grouping matches what the list always did: by the linked biomarker, or for a + * legacy unlinked row case-insensitively by its analyte name. + */ +import { prisma } from "@/lib/db"; +import { serialiseLabResult } from "@/lib/labs/serialise"; + +/** + * Readings per marker that feed the trend sparkline, newest kept. The + * sparkline is a 72px glyph; more points than this only overlap. The count + * beside it is the full count, not this cap. + */ +export const LAB_OVERVIEW_TREND_POINTS = 50; + +export interface LabOverviewMarker { + /** `bm:`, or `analyte:` for a legacy row. */ + key: string; + /** Every live reading of this marker. */ + count: number; + /** Values of the newest readings, oldest first; null for a qualitative one. */ + trend: (number | null)[]; + /** The newest reading, as the list endpoint serialises a row. */ + latest: ReturnType; +} + +/** + * One window-function read ranks every live row inside its marker, keeps the + * newest `LAB_OVERVIEW_TREND_POINTS` of each, and a second read loads the + * newest row per marker with its biomarker so it serialises exactly like a + * list row. Markers come back newest reading first. + */ +export async function loadLabOverview( + userId: string, +): Promise { + // Parameter-bound throughout. `id` breaks a `taken_at` tie the same way the + // list endpoint's order does, so "newest" means the same row on both. + const ranked = await prisma.$queryRaw< + Array<{ + id: string; + grp: string; + value: number | null; + rn: number; + n: number; + }> + >` + SELECT r."id", r."grp", r."value", r."rn"::int AS "rn", r."n"::int AS "n" + FROM ( + SELECT g."id", g."grp", g."value", + row_number() OVER ( + PARTITION BY g."grp" ORDER BY g."taken_at" DESC, g."id" DESC + ) AS "rn", + count(*) OVER (PARTITION BY g."grp") AS "n" + FROM ( + SELECT l."id", l."value", l."taken_at", + CASE + WHEN l."biomarker_id" IS NOT NULL THEN 'bm:' || l."biomarker_id" + ELSE 'analyte:' || lower(l."analyte") + END AS "grp" + FROM "lab_results" l + WHERE l."user_id" = ${userId} AND l."deleted_at" IS NULL + ) g + ) r + WHERE r."rn" <= ${LAB_OVERVIEW_TREND_POINTS} + `; + if (ranked.length === 0) return []; + + const groups = new Map< + string, + { + latestId: string | null; + count: number; + points: Array<[number, number | null]>; + } + >(); + for (const row of ranked) { + let group = groups.get(row.grp); + if (!group) { + group = { latestId: null, count: row.n, points: [] }; + groups.set(row.grp, group); + } + if (row.rn === 1) group.latestId = row.id; + group.points.push([row.rn, row.value]); + } + + const latestIds = [...groups.values()] + .map((g) => g.latestId) + .filter((id): id is string => id !== null); + const rows = await prisma.labResult.findMany({ + where: { userId, id: { in: latestIds } }, + include: { + biomarker: { + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + }, + }, + }, + }); + const byId = new Map(rows.map((row) => [row.id, row])); + + const markers: LabOverviewMarker[] = []; + for (const [key, group] of groups) { + const row = group.latestId ? byId.get(group.latestId) : undefined; + // Deleted between the two reads: the marker is gone, not half-shown. + if (!row) continue; + markers.push({ + key, + count: group.count, + trend: group.points.sort((a, b) => b[0] - a[0]).map(([, value]) => value), + latest: serialiseLabResult(row, row.biomarker ?? null), + }); + } + return markers.sort((a, b) => { + const byTime = Date.parse(b.latest.takenAt) - Date.parse(a.latest.takenAt); + if (byTime !== 0) return byTime; + return a.latest.id < b.latest.id ? 1 : a.latest.id > b.latest.id ? -1 : 0; + }); +} diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 3fcc7e5ca..27a25f337 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -12,6 +12,7 @@ import { z } from "zod/v4"; import { createLabResultSchema, + labOverviewQuerySchema, listLabResultsSchema, updateLabResultSchema, } from "@/lib/validations/labs"; @@ -116,6 +117,29 @@ const listResponse = z }) .meta({ id: "ListLabResultsResponse" }); +const labOverviewQuery = labOverviewQuerySchema.meta({ + id: "LabOverviewQuery", + description: + "The overview takes no query parameters; it always covers the whole live history. An unknown parameter is refused with 422.", +}); + +const labOverviewMarker = z + .object({ + key: z.string(), + count: z.number().int(), + trend: z.array(z.number().nullable()), + latest: labResultRow, + }) + .meta({ + id: "LabOverviewMarker", + description: + "One biomarker in the caller's lab history. `key` is `bm:` for a catalog-linked marker or `analyte:` for a legacy unlinked one. `count` is every live reading of the marker. `trend` holds the values of its newest readings (at most 50), oldest first, null for a qualitative reading. `latest` is the newest reading, serialised exactly like a `GET /api/labs` row.", + }); + +const labOverviewResponse = z + .object({ markers: z.array(labOverviewMarker) }) + .meta({ id: "LabOverviewResponse" }); + const notFound = { "404": { description: "Lab result not found (or owned by another user).", @@ -175,6 +199,27 @@ export const labsPaths: NonNullable = { }, }, }, + "/api/labs/overview": { + get: { + tags: ["Labs"], + summary: "Per-marker overview of the caller's lab history", + description: + "One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut off by a page size.", + requestParams: { query: labOverviewQuery }, + responses: { + ...recordRefusal(), + "200": { + description: "Lab overview.", + content: { + "application/json": { + schema: dataEnvelope(labOverviewResponse, "LabOverviewEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/labs/{id}": { get: { tags: ["Labs"], diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index c15eb86e1..ee585e878 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -29,6 +29,11 @@ export const labKeys = { params.sortDir, ] as const, + // The Labs list: one entry per biomarker over the whole history + // (`GET /api/labs/overview`). The `lab-results` prefix lets every result + // mutation's `labResults()` invalidation evict it. + labResultsOverview: () => ["lab-results", "overview"] as const, + // `/labs?analyte=` (MCP deep links): the newest reading stored under // that exact analyte name, one row, to find its marker's page. Its own key // because the page size differs from every list read; the `lab-results` diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index 6bbc3ef0d..834ef6721 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -306,6 +306,13 @@ export const listLabResultsSchema = z.object({ export type ListLabResultsInput = z.infer; +/** + * The per-marker overview takes no parameters: it always covers the whole + * live history. Strict, so a stray `limit` is refused rather than silently + * ignored by a caller expecting it to cap something. + */ +export const labOverviewQuerySchema = z.object({}).strict(); + /** * Reference-range classification lives in `@/lib/labs/reference-range` so the * API response, the doctor-report PDF, and the lab list card all share one diff --git a/tests/integration/labs-overview-long-history.test.ts b/tests/integration/labs-overview-long-history.test.ts new file mode 100644 index 000000000..bf0bbaa6c --- /dev/null +++ b/tests/integration/labs-overview-long-history.test.ts @@ -0,0 +1,276 @@ +/** + * `GET /api/labs/overview` against a real Postgres, on a long history. + * + * The Labs list used to group the newest 500 raw rows in the browser, so on + * an account with thousands of readings every marker measured only long ago + * had no row at all. The overview groups server-side over every live row; + * this suite seeds 2,000 readings over 25 years and checks that each marker + * comes back once, with its full count, its newest reading and a trend. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER_ID = "user-labs-overview-test"; +const OTHER_USER_ID = "user-labs-overview-other"; +const DAY_MS = 24 * 60 * 60 * 1000; + +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), +})); + +/** Days before now, so the fixture never ages out of its own windows. */ +const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS); + +const FREQUENT_READINGS = 1_700; +const RARE_MARKERS = 58; +const RARE_READINGS_EACH = 5; +const LEGACY_READINGS = 10; +const TOTAL = + FREQUENT_READINGS + RARE_MARKERS * RARE_READINGS_EACH + LEGACY_READINGS; + +interface Seeded { + frequentId: string; + rareIds: string[]; +} + +/** + * 2,000 live readings over 25 years: one marker measured almost daily over + * the last five years (1,700), 58 markers measured five times each between 25 + * and 6 years ago (290), and one legacy unlinked marker whose ten readings + * spell the analyte in different cases (10). The newest 500 rows are all the + * frequent marker, which is exactly the history the capped list lost. + */ +async function seedLongHistory(): Promise { + const prisma = getPrismaClient(); + const frequent = await prisma.biomarker.create({ + data: { + userId: USER_ID, + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 99, + }, + }); + const rare = await Promise.all( + Array.from({ length: RARE_MARKERS }, (_, i) => + prisma.biomarker.create({ + data: { + userId: USER_ID, + name: `Rare marker ${String(i).padStart(2, "0")}`, + unit: "U/L", + lowerBound: 1, + upperBound: 50, + }, + }), + ), + ); + + const rows: Array<{ + userId: string; + biomarkerId: string | null; + analyte: string; + value: number; + unit: string; + takenAt: Date; + }> = []; + for (let i = 0; i < FREQUENT_READINGS; i++) { + rows.push({ + userId: USER_ID, + biomarkerId: frequent.id, + analyte: "Glucose", + value: 80 + (i % 20), + unit: "mg/dL", + takenAt: daysAgo(1 + i), + }); + } + rare.forEach((marker, m) => { + for (let k = 0; k < RARE_READINGS_EACH; k++) { + rows.push({ + userId: USER_ID, + biomarkerId: marker.id, + analyte: marker.name, + value: 10 + k, + unit: "U/L", + // Between ~25 and ~6 years ago, one marker per fortnight-ish slot. + takenAt: daysAgo(25 * 365 - m * 120 - k * 20), + }); + } + }); + for (let k = 0; k < LEGACY_READINGS; k++) { + rows.push({ + userId: USER_ID, + biomarkerId: null, + analyte: k % 2 === 0 ? "Vitamin X" : "VITAMIN x", + value: 1 + k, + unit: "nmol/L", + takenAt: daysAgo(24 * 365 - k * 30), + }); + } + await prisma.labResult.createMany({ data: rows }); + + // Noise that must not count: a deleted reading of a rare marker, and + // another account's reading. + await prisma.labResult.create({ + data: { + userId: USER_ID, + biomarkerId: rare[0].id, + analyte: rare[0].name, + value: 99, + unit: "U/L", + takenAt: daysAgo(0), + deletedAt: daysAgo(0), + }, + }); + await prisma.labResult.create({ + data: { + userId: OTHER_USER_ID, + analyte: "Other account marker", + value: 1, + unit: "U/L", + takenAt: daysAgo(0), + }, + }); + return { frequentId: frequent.id, rareIds: rare.map((m) => m.id) }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + const prisma = getPrismaClient(); + await prisma.user.createMany({ + data: [ + { id: USER_ID, username: "labs-ov", email: "labs-ov@example.test" }, + { + id: OTHER_USER_ID, + username: "labs-ov-other", + email: "labs-ov-other@example.test", + }, + ], + }); + const session = await prisma.session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +interface OverviewBody { + data: { + markers: Array<{ + key: string; + count: number; + trend: (number | null)[]; + latest: { + id: string; + biomarkerId: string | null; + analyte: string; + value: number | null; + takenAt: string; + }; + }>; + }; +} + +async function getOverview(query = "") { + const { GET } = await import("@/app/api/labs/overview/route"); + return GET(new NextRequest(`http://localhost/api/labs/overview${query}`)); +} + +describe("GET /api/labs/overview on a 25-year history", () => { + it("returns every marker once, with its full count and newest reading", async () => { + const seeded = await seedLongHistory(); + + const res = await getOverview(); + expect(res.status).toBe(200); + const { markers } = ((await res.json()) as OverviewBody).data; + + // 1 frequent + 58 rare + 1 legacy, and nothing from the other account. + expect(markers).toHaveLength(1 + RARE_MARKERS + 1); + expect(markers.reduce((sum, m) => sum + m.count, 0)).toBe(TOTAL); + expect(TOTAL).toBe(2_000); + + const byKey = new Map(markers.map((m) => [m.key, m])); + for (const id of seeded.rareIds) { + const marker = byKey.get(`bm:${id}`); + expect(marker, `marker ${id} missing`).toBeDefined(); + // The deleted reading neither counts nor becomes the newest one. + expect(marker!.count).toBe(RARE_READINGS_EACH); + expect(marker!.latest.value).toBe(14); + expect(marker!.trend).toEqual([10, 11, 12, 13, 14]); + } + + const frequent = byKey.get(`bm:${seeded.frequentId}`)!; + expect(frequent.count).toBe(FREQUENT_READINGS); + // Capped trend, oldest first, ending on the newest reading. + expect(frequent.trend).toHaveLength(50); + expect(frequent.trend.at(-1)).toBe(frequent.latest.value); + expect(frequent.latest.value).toBe(80); + + // Legacy rows group case-insensitively by analyte. + const legacy = byKey.get("analyte:vitamin x")!; + expect(legacy.count).toBe(LEGACY_READINGS); + expect(legacy.latest.biomarkerId).toBeNull(); + expect(legacy.latest.value).toBe(LEGACY_READINGS); + + // Newest reading first. + expect(markers[0].key).toBe(`bm:${seeded.frequentId}`); + const times = markers.map((m) => Date.parse(m.latest.takenAt)); + expect([...times].sort((a, b) => b - a)).toEqual(times); + + expect( + markers.some((m) => m.latest.analyte === "Other account marker"), + ).toBe(false); + }); + + it("covers the markers a 500-row page of the list endpoint drops", async () => { + await seedLongHistory(); + const { GET } = await import("@/app/api/labs/route"); + const page = await GET( + new NextRequest("http://localhost/api/labs?limit=500&sortDir=desc"), + ); + const pageBody = (await page.json()) as { + data: { results: Array<{ biomarkerId: string | null }> }; + }; + const onPage = new Set(pageBody.data.results.map((r) => r.biomarkerId)); + // The old list's view of this account: one marker. + expect(onPage.size).toBe(1); + + const { markers } = ((await (await getOverview()).json()) as OverviewBody) + .data; + expect(markers).toHaveLength(1 + RARE_MARKERS + 1); + }); + + it("refuses a query parameter instead of ignoring it", async () => { + const res = await getOverview("?limit=500"); + expect(res.status).toBe(422); + }); + + it("answers an empty list for an account without readings", async () => { + const res = await getOverview(); + expect(res.status).toBe(200); + expect(((await res.json()) as OverviewBody).data.markers).toEqual([]); + }); +}); From 7c905249fb99ca108e26f7ea7128cbee1d7cb324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:05:51 +0200 Subject: [PATCH 18/40] fix(medications): page the side-effect list and name every GLP-1 medication in the timeline --- docs/api/openapi.yaml | 17 +++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + src/app/api/insights/glp1-timeline/route.ts | 11 +- .../insights/glp1-substance-curve-summary.tsx | 28 ++--- .../__tests__/side-effects-section.test.tsx | 114 +++++++++++++++++- .../medications/side-effects-section.tsx | 79 ++++++++++-- src/lib/openapi/routes/insights/schemas.ts | 5 + 13 files changed, 228 insertions(+), 33 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f48efd348..449bb3d13 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -61229,9 +61229,26 @@ components: additionalProperties: false description: "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them." + medications: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + additionalProperties: false + description: "Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the + entry names, to know which medications are GLP-1: a medication whose events are all older than the newest + `limit` entries has none in `entries`. Empty when `hasGlp1` is false." required: - hasGlp1 - entries + - medications additionalProperties: false description: "A chronological merge of the GLP-1-relevant events the record holds: titration steps, injections with their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days. diff --git a/messages/de.json b/messages/de.json index e7a8a1dd0..ba509f4b9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1386,6 +1386,7 @@ "emptyState": "Noch keine Nebenwirkungen erfasst. Tracking hilft dir und deiner Ärztin, Muster über die Zeit zu erkennen.", "recentTitle": "Letzte 30 Tage", "loading": "Wird geladen…", + "loadMore": "Weitere laden", "deleteCta": "Löschen", "deleteError": "Eintrag konnte nicht gelöscht werden", "errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen", diff --git a/messages/en.json b/messages/en.json index 11a517a80..7577e51fb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1386,6 +1386,7 @@ "emptyState": "No side effects logged yet. Tracking can help your doctor and surface patterns over time.", "recentTitle": "Last 30 days", "loading": "Loading…", + "loadMore": "Load more", "deleteCta": "Delete", "deleteError": "Could not delete the entry", "errorSeverity": "Severity must be between 1 and 5", diff --git a/messages/es.json b/messages/es.json index 43c808b32..aaa14812a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1386,6 +1386,7 @@ "emptyState": "Aún no hay efectos secundarios registrados. Hacer seguimiento puede ayudar a tu médico y revelar patrones a lo largo del tiempo.", "recentTitle": "Últimos 30 días", "loading": "Cargando…", + "loadMore": "Cargar más", "deleteCta": "Eliminar", "deleteError": "No se pudo eliminar la entrada", "errorSeverity": "La gravedad debe estar entre 1 y 5", diff --git a/messages/fr.json b/messages/fr.json index 3f3de9ecd..24d926b6c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1386,6 +1386,7 @@ "emptyState": "Aucun effet secondaire consigné. Le suivi peut aider votre médecin et révéler des tendances dans le temps.", "recentTitle": "30 derniers jours", "loading": "Chargement…", + "loadMore": "Charger plus", "deleteCta": "Supprimer", "deleteError": "Impossible de supprimer l'entrée", "errorSeverity": "La sévérité doit être entre 1 et 5", diff --git a/messages/it.json b/messages/it.json index 4853184ed..71f3977a7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1386,6 +1386,7 @@ "emptyState": "Nessun effetto collaterale registrato. Tracciarli può aiutare il medico e far emergere schemi nel tempo.", "recentTitle": "Ultimi 30 giorni", "loading": "Caricamento…", + "loadMore": "Carica altri", "deleteCta": "Elimina", "deleteError": "Impossibile eliminare la voce", "errorSeverity": "La gravità deve essere tra 1 e 5", diff --git a/messages/ko.json b/messages/ko.json index 122c25d92..04eb45df2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1386,6 +1386,7 @@ "emptyState": "아직 기록한 부작용이 없어요. 기록해 두면 진료에 도움이 되고 패턴도 드러나요.", "recentTitle": "최근 30일", "loading": "불러오는 중…", + "loadMore": "더 불러오기", "deleteCta": "삭제", "deleteError": "기록을 삭제하지 못했어요", "errorSeverity": "심각도는 1에서 5 사이여야 해요", diff --git a/messages/pl.json b/messages/pl.json index 5907e3c21..68e21c4cd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1386,6 +1386,7 @@ "emptyState": "Nie zarejestrowano jeszcze skutków ubocznych. Śledzenie może pomóc lekarzowi i ujawnić wzorce w czasie.", "recentTitle": "Ostatnie 30 dni", "loading": "Ładowanie…", + "loadMore": "Załaduj więcej", "deleteCta": "Usuń", "deleteError": "Nie udało się usunąć wpisu", "errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5", diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 67af7ff9c..2b59c53c9 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -68,7 +68,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }); if (meds.length === 0) { - return apiSuccess({ hasGlp1: false, entries: [] }); + return apiSuccess({ hasGlp1: false, entries: [], medications: [] }); } const ninetyDaysAgo = new Date(); @@ -173,5 +173,12 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); - return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) }); + // `medications` is every GLP-1 medication, independent of `limit`: a + // consumer that needs the set (the substance-curve summary) must not + // derive it from a capped page of entries. + return apiSuccess({ + hasGlp1: true, + entries: entries.slice(0, limit), + medications: meds.map((med) => ({ id: med.id, name: med.name })), + }); }); diff --git a/src/components/insights/glp1-substance-curve-summary.tsx b/src/components/insights/glp1-substance-curve-summary.tsx index 9fc178a08..80c6a7044 100644 --- a/src/components/insights/glp1-substance-curve-summary.tsx +++ b/src/components/insights/glp1-substance-curve-summary.tsx @@ -9,9 +9,12 @@ * * Gating: reads `GET /api/insights/glp1-timeline` — the server-authoritative * GLP-1 signal. When `hasGlp1` is false the block renders nothing. The set of - * GLP-1 medication names comes from the timeline's entries (every entry is - * from a `treatmentClass === "GLP1"` medication), and the passed medication - * list is filtered to that set so non-GLP-1 medications never render a curve. + * GLP-1 medications comes from the response's `medications` list, which the + * server fills from every `treatmentClass === "GLP1"` medication independent + * of the entry cap, and the passed medication list is filtered to that set so + * non-GLP-1 medications never render a curve. (It used to be derived from the + * names in a capped page of entries, which dropped a medication whose events + * were all older than that page.) * * The chart carries its own `MedicationDetailSection` card chrome + estimate * disclaimer, so it reads as one system with the surrounding Insights cards @@ -36,7 +39,7 @@ const DrugLevelChart = dynamic( interface Glp1TimelineResponse { hasGlp1: boolean; - entries: { medicationName?: string }[]; + medications: { id: string; name: string }[]; } export function Glp1SubstanceCurveSummary({ @@ -45,24 +48,17 @@ export function Glp1SubstanceCurveSummary({ medications: { id: string; name: string; dose: string }[]; }) { const { data } = useQuery({ - queryKey: queryKeys.insightsGlp1Timeline(200), + // Only `medications` is read, so the smallest page of entries will do. + queryKey: queryKeys.insightsGlp1Timeline(1), queryFn: () => - apiGet("/api/insights/glp1-timeline?limit=200"), + apiGet("/api/insights/glp1-timeline?limit=1"), staleTime: 60_000, }); if (!data?.hasGlp1) return null; - // Every timeline entry is from a GLP-1 medication (server filters on - // `treatmentClass: "GLP1"`). Medication names match the comprehensive - // list exactly — both derive from `medication.name` server-side. - const glp1Names = new Set( - data.entries - .map((e) => e.medicationName) - .filter((n): n is string => typeof n === "string" && n.length > 0), - ); - - const glp1Meds = medications.filter((m) => glp1Names.has(m.name)); + const glp1Ids = new Set(data.medications.map((m) => m.id)); + const glp1Meds = medications.filter((m) => glp1Ids.has(m.id)); if (glp1Meds.length === 0) return null; const showNameLabel = glp1Meds.length > 1; diff --git a/src/components/medications/__tests__/side-effects-section.test.tsx b/src/components/medications/__tests__/side-effects-section.test.tsx index 4555b07c6..7737bd290 100644 --- a/src/components/medications/__tests__/side-effects-section.test.tsx +++ b/src/components/medications/__tests__/side-effects-section.test.tsx @@ -87,8 +87,14 @@ function seedSideEffects( }>, ) { client.setQueryData(queryKeys.medicationSideEffects(medId), { - items: rows, - meta: { total: rows.length }, + pages: [ + { + items: rows, + meta: { total: rows.length }, + from: "2026-01-01T00:00:00.000Z", + }, + ], + pageParams: [null], }); } @@ -131,13 +137,24 @@ async function executeSideEffectsQuery( medicationId: string, ): Promise { const queryKey = queryKeys.medicationSideEffects(medicationId); - const queryFn = client.getQueryCache().find({ queryKey, exact: true }) - ?.options.queryFn; - if (typeof queryFn !== "function") { + const options = client.getQueryCache().find({ queryKey, exact: true }) + ?.options as + | { + queryFn?: unknown; + initialPageParam?: unknown; + getNextPageParam?: unknown; + } + | undefined; + if (typeof options?.queryFn !== "function") { throw new Error("Side-effects query function was not registered"); } try { - await client.fetchQuery({ queryKey, queryFn }); + await client.fetchInfiniteQuery({ + queryKey, + queryFn: options.queryFn as never, + initialPageParam: options.initialPageParam as never, + getNextPageParam: options.getNextPageParam as never, + }); } catch { // The settled error is the state under test. } @@ -420,3 +437,88 @@ describe(" — timeline rows", () => { expect(html).toContain("Mittel"); }); }); + +describe(" — paging", () => { + function sideEffectsResponse( + items: Array<{ id: string; occurredAt: string }>, + ): Response { + return new Response( + JSON.stringify({ + data: { + items: items.map((row) => ({ + ...row, + category: "GI", + entry: "NAUSEA", + severity: 2, + notes: null, + })), + meta: { total: items.length }, + }, + error: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + it("offers a next page after a full one and asks for the entries before it", async () => { + const base = Date.parse("2026-05-20T12:00:00.000Z"); + const firstPage = Array.from({ length: 50 }, (_, i) => ({ + id: `se-${i}`, + occurredAt: new Date(base - i * 60_000).toISOString(), + })); + const oldest = firstPage[firstPage.length - 1]; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(sideEffectsResponse(firstPage)) + // The boundary entry comes back once more and is shown once. + .mockResolvedValueOnce( + sideEffectsResponse([ + oldest, + { id: "se-older", occurredAt: "2026-05-01T08:00:00.000Z" }, + ]), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = makeClient(); + render(, client); + await executeSideEffectsQuery(client, "med-paged"); + await waitForQueryStatus(client, "med-paged", "success"); + + const firstHtml = render( + , + client, + ); + expect(firstHtml).toContain(en.medications.sideEffects.loadMore); + + await client + .getQueryCache() + .find({ + queryKey: queryKeys.medicationSideEffects("med-paged"), + exact: true, + }) + ?.fetch(undefined, { meta: { fetchMore: { direction: "forward" } } }); + + const secondUrl = new URL( + String(fetchMock.mock.calls[1]?.[0]), + "http://localhost", + ); + expect(secondUrl.searchParams.get("to")).toBe( + new Date(Date.parse(oldest.occurredAt) + 1).toISOString(), + ); + const firstUrl = new URL( + String(fetchMock.mock.calls[0]?.[0]), + "http://localhost", + ); + expect(secondUrl.searchParams.get("from")).toBe( + firstUrl.searchParams.get("from"), + ); + + const secondHtml = render( + , + client, + ); + // 50 + 1 new, the repeated boundary entry dropped; a short page ends it. + expect(secondHtml).toContain("(51)"); + expect(secondHtml).not.toContain(en.medications.sideEffects.loadMore); + }); +}); diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 7df4d2477..140fb8edb 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -3,7 +3,11 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useId, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; import { toast } from "sonner"; import { Loader2, Plus, Trash2 } from "lucide-react"; @@ -110,6 +114,16 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string { .replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); } +/** Entries per page of the side-effect list. */ +const SIDE_EFFECTS_PAGE_SIZE = 50; + +interface SideEffectPageParam { + /** The window start, fixed when the first page loaded. */ + from: string; + /** Exclusive upper bound: just after the oldest entry already shown. */ + to: string; +} + export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const { t } = useTranslations(); const { canWriteDomain, canManageDomain } = useRecordCapabilities(); @@ -133,18 +147,40 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const listKey = queryKeys.medicationSideEffects(medicationId); - const { data, isLoading, isError, refetch } = useQuery({ + // The last 30 days, newest first, one page at a time. The window is fixed + // when the first page loads; each further page asks for the entries before + // the oldest one shown (`to` is exclusive, so the boundary instant is + // re-asked one millisecond later and the overlap is dropped by id). + const { + data, + isLoading, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ queryKey: listKey, - queryFn: async () => { - // Last 30 days, newest-first. - const to = new Date(); - const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); - const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=100`; + initialPageParam: null as SideEffectPageParam | null, + queryFn: async ({ pageParam }) => { + const to = pageParam ? new Date(pageParam.to) : new Date(); + const from = pageParam + ? new Date(pageParam.from) + : new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); + const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`; const { data: payload } = await apiFetchEnvelope<{ items: SideEffectRow[]; meta: { total: number }; }>(url); - return payload; + return { ...payload, from: from.toISOString() }; + }, + getNextPageParam: (lastPage): SideEffectPageParam | undefined => { + if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined; + const oldest = lastPage.items[lastPage.items.length - 1]; + return { + from: lastPage.from, + to: new Date(Date.parse(oldest.occurredAt) + 1).toISOString(), + }; }, staleTime: 30 * 1000, }); @@ -184,7 +220,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { }, }); - const items = data?.items ?? []; + const items = useMemo(() => { + const seen = new Set(); + const out: SideEffectRow[] = []; + for (const page of data?.pages ?? []) { + for (const row of page.items) { + if (seen.has(row.id)) continue; + seen.add(row.id); + out.push(row); + } + } + return out; + }, [data]); const filteredEntries = useMemo( () => entriesByCategory(category), @@ -363,6 +410,20 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { ))} + {hasNextPage ? ( +
    + +
    + ) : null}
    )} diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index f13be444c..6f77494fd 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2878,6 +2878,11 @@ export const glp1TimelineResponse = z .describe( "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them.", ), + medications: z + .array(z.object({ id: z.string(), name: z.string() })) + .describe( + "Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the entry names, to know which medications are GLP-1: a medication whose events are all older than the newest `limit` entries has none in `entries`. Empty when `hasGlp1` is false.", + ), }) .meta({ id: "Glp1TimelineResponse", From b17a25089422dca1faa41237549fab738a04bd15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:06:02 +0200 Subject: [PATCH 19/40] feat(record-settings): publish connection health for a managed record's integrations The guardian's read of a managed record's connections now carries the same server-resolved health the owner's own envelope does, loaded for the record and never for the guardian. The legacy state stays on the wire for one transition release. --- docs/api/openapi.yaml | 26 +- .../api/record-settings/integrations/route.ts | 9 + .../managed-integration-status.test.tsx | 133 ++++ .../__tests__/cards-render-health.test.ts | 160 +++++ .../integrations/__tests__/health-fixture.ts | 25 + .../__tests__/integration-card.test.tsx | 403 ++++++++++++ .../__tests__/integration-health-copy.test.ts | 275 ++++++++ .../integrations/credentials-form.tsx | 175 +++++ .../integrations/integration-actions.tsx | 208 ++++++ .../integrations/integration-card.tsx | 603 ++++++++++++++++++ .../integrations/integration-health-copy.ts | 358 +++++++++++ .../integrations/metric-freshness-list.tsx | 162 +++++ .../integrations/oauth-integration-card.tsx | 539 ++++++++++++++++ src/lib/openapi/routes/integrations.ts | 2 +- src/lib/openapi/routes/record-settings.ts | 10 +- ...ecord-settings-integrations-health.test.ts | 125 ++++ 16 files changed, 3201 insertions(+), 12 deletions(-) create mode 100644 src/components/settings/__tests__/managed-integration-status.test.tsx create mode 100644 src/components/settings/integrations/__tests__/cards-render-health.test.ts create mode 100644 src/components/settings/integrations/__tests__/health-fixture.ts create mode 100644 src/components/settings/integrations/__tests__/integration-card.test.tsx create mode 100644 src/components/settings/integrations/__tests__/integration-health-copy.test.ts create mode 100644 src/components/settings/integrations/credentials-form.tsx create mode 100644 src/components/settings/integrations/integration-actions.tsx create mode 100644 src/components/settings/integrations/integration-card.tsx create mode 100644 src/components/settings/integrations/integration-health-copy.ts create mode 100644 src/components/settings/integrations/metric-freshness-list.tsx create mode 100644 src/components/settings/integrations/oauth-integration-card.tsx create mode 100644 tests/integration/record-settings-integrations-health.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 17fd03bc0..133ffc152 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -22032,10 +22032,11 @@ paths: tags: - Account sharing summary: Read the managed record's integration connection states - description: "Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There - is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot - connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a - record selector. Cookie session or Bearer, while acting on a managed record the caller guards." + description: "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its + server-resolved health. There is deliberately no control arm and no credential projection anywhere under this + path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow + cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller + guards." responses: "200": description: Connection state per provider. @@ -79496,11 +79497,18 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + health: + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" + description: The same server-resolved connection state the owner's own envelope carries, null while the provider is not + connected. The web renders this; `state` stays for one transition release. required: - integration - state - lastSuccessAt - lastAttemptAt + - health additionalProperties: false description: All eight providers, always, in the fixed order withings, whoop, fitbit, nightscout, polar, oura, google-health, strava. Note the order differs from the owner's own `/api/integrations/status` envelope. @@ -79508,11 +79516,11 @@ components: - recordId - integrations additionalProperties: false - description: Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no - credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see - that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real - credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is - not proof of a connection. + description: Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. + Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record + selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. + A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a + synthesised healthy ledger is not proof of a connection. GetRecordSettingsFamilyEnvelope: type: object properties: diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts index 295e24733..f649858eb 100644 --- a/src/app/api/record-settings/integrations/route.ts +++ b/src/app/api/record-settings/integrations/route.ts @@ -9,6 +9,7 @@ import { getIntegrationStatus, type IntegrationKey, } from "@/lib/integrations/status"; +import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; import { annotate } from "@/lib/logging/context"; import { resolveGuardianRecordSettingsAccess, @@ -70,6 +71,13 @@ export const GET = apiHandler(async () => { oura: context.user.ouraAccessTokenEncrypted !== null, strava: context.user.stravaAccessTokenEncrypted !== null, }; + // The one server-resolved state per connection, from the same loader the + // owner's own envelope uses, so a guardian reads the record's connections in + // the words the record's owner would. + const health = await loadConnectionHealth( + access.recordId, + MANAGED_INTEGRATIONS, + ); const integrations = statuses.map((status) => ({ integration: status.integration, state: resolveManagedIntegrationState( @@ -79,6 +87,7 @@ export const GET = apiHandler(async () => { ), lastSuccessAt: status.lastSuccessAt, lastAttemptAt: status.lastAttemptAt, + health: health[status.integration], })); annotate({ action: { name: "record-settings.integrations.get" } }); diff --git a/src/components/settings/__tests__/managed-integration-status.test.tsx b/src/components/settings/__tests__/managed-integration-status.test.tsx new file mode 100644 index 000000000..8cc091ea6 --- /dev/null +++ b/src/components/settings/__tests__/managed-integration-status.test.tsx @@ -0,0 +1,133 @@ +/** + * A managed record's connections on the shared integration card, read-only. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "guardian", + accountAccess: { + active: { + accountId: "record-1", + recordKind: "managed", + displayName: "Managed record", + }, + }, + }, + isAuthenticated: true, + isLoading: false, + }), +})); + +let payload: unknown; +let loading = false; +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: payload, + isLoading: loading, + isError: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ManagedIntegrationStatus } from "../managed-integration-status"; +import { healthFixture } from "../integrations/__tests__/health-fixture"; + +const KEYS = [ + "withings", + "whoop", + "fitbit", + "nightscout", + "polar", + "oura", + "google-health", + "strava", +]; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +function withHealth(map: Record) { + return { + recordId: "record-1", + integrations: KEYS.map((integration) => ({ + integration, + // The legacy fields still ride the wire; the view must not use them. + state: "connected", + lastSuccessAt: null, + lastAttemptAt: null, + health: map[integration] ?? null, + })), + }; +} + +describe("", () => { + beforeEach(() => { + payload = undefined; + loading = false; + }); + + it("renders one shared card per provider from health, with no control on any", () => { + payload = withHealth({ + oura: healthFixture({ + state: "action_required", + reason: "reauth_required", + action: "reconnect", + }), + withings: healthFixture(), + }); + const html = render(); + + expect(html).toContain('data-record-settings-family="integrations"'); + expect(html).toContain( + 'aria-labelledby="managed-integration-status-title"', + ); + expect(html).toContain('data-record-id="record-1"'); + expect(html.match(/data-slot="integration-card"/g)).toHaveLength(8); + for (const key of KEYS) { + expect(html).toContain(`data-testid="managed-integration-${key}"`); + } + expect(html).toMatch( + /data-testid="managed-integration-oura"[\s\S]*?data-slot="integration-health-badge"[^>]*data-state="action_required"/, + ); + // Read-only even where the state asks for an action. + expect(html).not.toContain('data-slot="integration-primary-action"'); + expect(html).not.toContain('data-slot="integration-menu-trigger"'); + // The legacy `state: "connected"` on every row does not make a card + // connected; only `health` does. + expect(html.match(/>Not connected\. { + payload = withHealth({}); + const html = render(); + expect(html).toContain('data-slot="managed-integration-read-only"'); + expect(html).toContain( + "A managed profile shows its connected services as a status only.", + ); + expect(html).toContain( + 'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"', + ); + }); + + it("paints the cards while loading without claiming a state", () => { + loading = true; + const html = render(); + expect(html).toContain('aria-busy="true"'); + expect(html).not.toContain( + 'aria-labelledby="managed-integration-status-title"', + ); + expect(html).not.toContain('data-slot="integration-health-badge"'); + expect(html).not.toContain("Not connected."); + }); +}); diff --git a/src/components/settings/integrations/__tests__/cards-render-health.test.ts b/src/components/settings/integrations/__tests__/cards-render-health.test.ts new file mode 100644 index 000000000..5ae749d7e --- /dev/null +++ b/src/components/settings/integrations/__tests__/cards-render-health.test.ts @@ -0,0 +1,160 @@ +/** + * The integration cards render the server's `health` and nothing else. + * + * Before v1.42.4 each card combined the ledger state, the liveness verdict, + * token expiry, plan and scope lists and a reauth flag in its own way, so one + * fact could read as a red error on one card and a footnote on the next. The + * server now folds all of it into `health`, and this pins that no card under + * this folder starts reading the old fields again, which is the one way a + * second status dialect could come back. The managed-profile status view is + * held to the same rule. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; + +import { walkSourceFiles } from "@/__tests__/helpers/source-files"; + +import { failureStreakFor, pickStatus } from "../shared"; + +describe("failureStreakFor", () => { + it("pairs the largest per-kind streak with the envelope threshold", () => { + const status = pickStatus( + { + threshold: 3, + integrations: [ + { + integration: "withings", + consecutiveFailuresByKind: { + transient: 1, + reauth_required: 2, + persistent: 0, + }, + }, + ], + }, + "withings", + ); + expect(failureStreakFor(status)).toEqual({ count: 2, threshold: 3 }); + }); + + it("reports nothing for a healthy zero-count status", () => { + const status = pickStatus( + { + threshold: 3, + integrations: [ + { + integration: "withings", + consecutiveFailuresByKind: { + transient: 0, + reauth_required: 0, + persistent: 0, + }, + }, + ], + }, + "withings", + ); + expect(failureStreakFor(status)).toBeNull(); + }); +}); + +describe("structural guard: cards read health, not the retired fields", () => { + const SRC = join(process.cwd(), "src"); + const CARDS = "components/settings/integrations"; + const MANAGED = "components/settings/managed-integration-status.tsx"; + + /** + * Fields the server keeps on the wire for one transition release and that + * `health` replaces. Matched as property reads in the parsed source, so a + * comment or an i18n key that happens to contain the word is not a read. + */ + const RETIRED = new Set([ + "lastError", + "state", + "needsReauth", + "planUnavailable", + "scopeReconnect", + "syncHealth", + "tokenExpired", + "tokenExpiresAt", + "hasActivityScope", + "legacyLastSyncedAt", + "consecutiveFailures", + ]); + + /** + * `.state` is also the name of `health.state` and of a Google Health sync + * run's progress state. Those bases are the allowed readers; any other base + * reading `.state` is a ledger state finding its way back onto a card. + */ + const STATE_READERS = /(^|\.)(health|progress|syncProgress|record)$/; + + const files = [ + ...walkSourceFiles(join(SRC, CARDS), { floor: 15 }) + .filter((p) => !p.includes("__tests__") && !p.endsWith(".test.ts")) + .map((p) => `${CARDS}/${p}`), + MANAGED, + ]; + + function retiredReads(rel: string): string[] { + const path = join(SRC, rel); + const file = ts.createSourceFile( + path, + readFileSync(path, "utf8"), + ts.ScriptTarget.Latest, + true, + path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const found: string[] = []; + function visit(node: ts.Node): void { + if (ts.isPropertyAccessExpression(node)) { + const name = node.name.text; + const base = node.expression.getText(file).replace(/\?$/, ""); + if ( + RETIRED.has(name) && + !(name === "state" && STATE_READERS.test(base)) + ) { + found.push(`${rel}: ${base}.${name}`); + } + } + ts.forEachChild(node, visit); + } + visit(file); + return found; + } + + it("walks the cards and the managed view", () => { + expect(files.length).toBeGreaterThan(10); + expect(files).toContain(`${CARDS}/integration-card.tsx`); + expect(files).toContain(`${CARDS}/oauth-integration-card.tsx`); + expect(files).toContain(MANAGED); + }); + + it("no card reads a retired health-shaped field", () => { + expect(files.flatMap(retiredReads)).toEqual([]); + }); + + it("the card data types do not declare them either", () => { + // The compiler is the stronger half of this guard: a field the view model + // does not declare cannot be read without a cast. + const shared = readFileSync(join(SRC, CARDS, "shared.tsx"), "utf8"); + const viewModel = shared.slice( + shared.indexOf("export interface IntegrationStatusViewModel"), + shared.indexOf("export interface IntegrationStatusEnvelope"), + ); + expect(viewModel.length).toBeGreaterThan(100); + for (const field of RETIRED) { + expect(viewModel, field).not.toMatch(new RegExp(`\\b${field}\\??:`)); + } + }); + + it("no card paints the old status pill", () => { + const offenders = files.filter((rel) => + / = {}, +): ConnectionHealth { + return { + state: "ok", + reason: "data_arriving", + since: null, + action: null, + unavailable: [], + lastSuccessAt: "2026-10-15T08:12:00.000Z", + lastDataAt: "2026-10-15T07:12:00.000Z", + diagnostics: { + ledgerState: "connected", + failingLegs: [], + failingSince: null, + lastAttemptAt: "2026-10-15T08:12:00.000Z", + verdict: "fresh", + recentCalls: [], + }, + ...overrides, + }; +} diff --git a/src/components/settings/integrations/__tests__/integration-card.test.tsx b/src/components/settings/integrations/__tests__/integration-card.test.tsx new file mode 100644 index 000000000..feedd6ee3 --- /dev/null +++ b/src/components/settings/integrations/__tests__/integration-card.test.tsx @@ -0,0 +1,403 @@ +/** + * The shared integration card, one test per state the server can publish. + * + * Every assertion reads the markup's data attributes or the bundle's own + * sentence, never a computed state: the card renders `health` and nothing + * else, so what is checked here is that each `health` lands on the right + * badge, sentence and (at most one) button. + */ +import { describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +let lastCardProps: IntegrationCardProps | null = null; +vi.mock("../integration-card", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IntegrationCard: (props: IntegrationCardProps) => { + lastCardProps = props; + return createElement(actual.IntegrationCard, props); + }, + }; +}); + +import type { Locale } from "@/lib/i18n/config"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; + +import type { IntegrationCardProps } from "../integration-card"; +import { + OAUTH_INTEGRATIONS, + OAuthIntegrationCard, + type OAuthIntegrationKey, +} from "../oauth-integration-card"; +import type { IntegrationStatusViewModel } from "../shared"; +import { healthFixture } from "./health-fixture"; + +const NOW = Date.now(); +const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString(); + +function render( + provider: OAuthIntegrationKey, + viewModel: Partial | undefined, + locale: Locale = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +function connected(health: ConnectionHealth, extra = {}) { + return { + connected: true, + configured: true, + available: true, + health, + ...extra, + }; +} + +function badgeState(html: string): string | null { + return ( + html.match( + /data-slot="integration-health-badge"[^>]*data-state="(\w+)"/, + )?.[1] ?? null + ); +} + +function primaryActions(html: string): string[] { + return [ + ...html.matchAll( + /data-slot="integration-primary-action"[^>]*data-action="(\w+)"/g, + ), + ].map((m) => m[1]); +} + +describe(" per state", () => { + it("ok: Working, one sentence, last data, no button", () => { + const html = render( + "oura", + connected(healthFixture({ lastDataAt: hoursAgo(1) })), + ); + expect(html).toMatch(/data-slot="integration-card"[^>]*data-state="ok"/); + expect(badgeState(html)).toBe("ok"); + expect(html).toContain(">Working<"); + expect(html).toContain("Data is arriving."); + expect(html).toContain('data-slot="integration-last-data"'); + expect(html).toContain("Last data: today,"); + expect(primaryActions(html)).toEqual([]); + // Details are always there, collapsed. + expect(html).toContain('data-slot="integration-details-toggle"'); + expect(html).toMatch( + /data-slot="integration-details"[^>]*data-state="closed"/, + ); + expect(html).toMatch( + /]*hidden=""[^>]*data-slot="integration-details"/, + ); + }); + + it("ok while older history loads: the sentence says so", () => { + const html = render( + "whoop", + connected(healthFixture(), { backfillCompleted: false }), + ); + expect(html).toContain( + "Data is arriving. Older history is loading in the background.", + ); + }); + + it("limited by plan: the sentence names up to three, counts the rest, no button", () => { + const html = render( + "withings", + connected( + healthFixture({ + state: "limited", + reason: "plan_limited", + unavailable: [ + { item: "spo2", cause: "plan" }, + { item: "vascularAge", cause: "plan" }, + { item: "pulseWaveVelocity", cause: "plan" }, + { item: "vo2Max", cause: "plan" }, + { item: "ecg", cause: "plan" }, + ], + }), + ), + ); + expect(badgeState(html)).toBe("limited"); + expect(html).toContain(">Limited<"); + expect(html).toContain( + "Data is arriving. Your Withings plan does not include SpO2, vascular age, and 3 more.", + ); + expect(primaryActions(html)).toEqual([]); + // The full list lives in the details, with the plans page one tap away. + const details = html.slice(html.indexOf('data-slot="integration-details"')); + expect(details).toContain("ECG and AFib"); + expect(details).toContain('data-testid="withings-plan-link"'); + }); + + it("limited by permission: the sentence names it and offers the one reconnect", () => { + const html = render( + "oura", + connected( + healthFixture({ + state: "limited", + reason: "permission_limited", + action: "reconnect", + unavailable: [ + { item: "spo2", cause: "permission" }, + { item: "vo2max", cause: "permission" }, + ], + }), + ), + ); + expect(badgeState(html)).toBe("limited"); + expect(html).toContain( + "Data is arriving. Permission is missing for SpO2 and VO2 max.", + ); + expect(primaryActions(html)).toEqual(["reconnect"]); + expect(html).toContain(">Reconnect<"); + }); + + it("degraded: Delayed, dated, reassuring, no button", () => { + const html = render( + "withings", + connected( + healthFixture({ + state: "degraded", + reason: "provider_errors", + since: hoursAgo(2), + }), + ), + ); + expect(badgeState(html)).toBe("degraded"); + expect(html).toContain(">Delayed<"); + expect(html).toMatch( + /Withings has not answered reliably since (today|yesterday), [^.]+\. HealthLog keeps trying; nothing to do on your side\./, + ); + expect(primaryActions(html)).toEqual([]); + }); + + it("action required, reauth: Action needed and exactly one Reconnect", () => { + const html = render( + "google-health", + connected( + healthFixture({ + state: "action_required", + reason: "reauth_required", + action: "reconnect", + }), + ), + ); + expect(badgeState(html)).toBe("action_required"); + expect(html).toContain(">Action needed<"); + expect(html).toContain( + "The Google Health connection has expired. Reconnect to keep data coming.", + ); + expect(primaryActions(html)).toEqual(["reconnect"]); + // The re-consent banner it replaces is gone. + expect(html).not.toContain("google-health-reauth-banner"); + }); + + it("action required, paused: exactly one Resume, wired to the resume endpoint", () => { + const html = render( + "whoop", + connected( + healthFixture({ + state: "action_required", + reason: "paused_after_errors", + since: hoursAgo(30), + action: "resume", + }), + ), + ); + expect(badgeState(html)).toBe("action_required"); + expect(html).toContain("WHOOP is paused after failing since"); + expect(primaryActions(html)).toEqual(["resume"]); + expect(html).toContain(">Resume<"); + expect(html).toContain('data-testid="whoop-resume-button"'); + expect(html).not.toContain("whoop-parked-banner"); + }); + + it("not connected: no badge, one sentence, the BYO form and Connect", () => { + const html = render("withings", { + connected: false, + configured: false, + health: null, + }); + expect(html).toMatch( + /data-slot="integration-card"[^>]*data-state="not_connected"/, + ); + expect(badgeState(html)).toBeNull(); + expect(html).toContain("Not connected."); + expect(html).toContain('data-testid="withings-setup-guide"'); + expect(html).toContain('data-testid="withings-redirect-guide"'); + expect(html).toContain('id="withings-clientid"'); + expect(html).toContain('data-testid="withings-connect"'); + // Connecting needs stored credentials first, and the card says so. + expect(html).toMatch( + /data-testid="withings-connect"[^>]*disabled=""|disabled=""[^>]*data-testid="withings-connect"/, + ); + expect(html).toContain('data-testid="integration-unavailable"'); + expect(html).not.toContain('data-slot="integration-details-toggle"'); + }); + + it("loading: paints the card and its header, says nothing about state", () => { + const html = render("polar", undefined); + expect(html).toMatch( + /data-slot="integration-card"[^>]*data-state="loading"/, + ); + expect(badgeState(html)).toBeNull(); + expect(html).not.toContain("Not connected."); + }); + + it("speaks German with the maintainer's words", () => { + const html = render( + "oura", + connected( + healthFixture({ + state: "degraded", + reason: "unexpected_silence", + since: hoursAgo(50), + }), + ), + "de", + ); + expect(html).toContain(">Verzögert<"); + expect(html).toContain("obwohl Oura sonst täglich liefert."); + expect(html).toContain("Details für eine Fehlermeldung"); + }); +}); + +describe(" menu and actions", () => { + it("moves sync, full reload, test, data, credentials, guide and disconnect into the menu", () => { + render("withings", connected(healthFixture())); + const menu = lastCardProps?.menu ?? []; + expect(menu.map((i) => i.id)).toEqual([ + "sync", + "full-sync", + "test", + "view-data", + "credentials", + "setup-guide", + "disconnect", + ]); + // The heavy and the destructive entries keep their confirmation. + expect(menu.find((i) => i.id === "full-sync")?.confirm).toBeTruthy(); + const disconnect = menu.find((i) => i.id === "disconnect"); + expect(disconnect?.destructive).toBe(true); + expect(disconnect?.confirm?.confirmLabel).toBe("Disconnect"); + }); + + it("offers no full reload where the provider has none", () => { + render("strava", connected(healthFixture())); + expect((lastCardProps?.menu ?? []).map((i) => i.id)).not.toContain( + "full-sync", + ); + }); + + it("names the menu trigger for screen readers and keeps the 44 px floor", () => { + const html = render("oura", connected(healthFixture())); + expect(html).toContain('aria-label="More actions for Oura"'); + const trigger = html.match( + /]*data-slot="integration-menu-trigger"[^>]*>/, + )?.[0]; + expect(trigger).toBeDefined(); + expect(trigger).toMatch(/\bmin-h-11\b/); + expect(trigger).toMatch(/\bmin-w-11\b/); + expect(trigger).toContain('aria-haspopup="menu"'); + }); + + it("shows the badge word as readable text, not an icon or a hidden label", () => { + const html = render( + "oura", + connected(healthFixture({ state: "degraded", reason: "no_new_data" })), + ); + const badge = html.match( + /]*data-slot="integration-health-badge"[^>]*>([\s\S]*?)<\/span>(?=<\/div>)/, + )?.[1]; + expect(badge).toContain("Delayed"); + expect(html).not.toMatch( + /data-slot="integration-health-badge"[^>]*aria-hidden="true"/, + ); + }); +}); + +describe(" details for a bug report", () => { + it("shows exactly the redacted diagnostics the copy button copies", () => { + const secret = "Oura error: token=abc.def.ghi for user user-42"; + const html = render( + "oura", + connected( + healthFixture({ + state: "degraded", + reason: "provider_errors", + since: "2026-10-14T18:40:00.000Z", + lastSuccessAt: "2026-10-14T18:12:00.000Z", + lastDataAt: "2026-10-14T07:55:00.000Z", + diagnostics: { + ledgerState: "error_transient", + failingLegs: ["sleep"], + failingSince: "2026-10-14T18:40:00.000Z", + lastAttemptAt: "2026-10-15T09:15:00.000Z", + verdict: "failing", + recentCalls: [ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T09:15:00.000Z", + errorCode: "503", + }, + ], + }, + }), + { + lastError: secret, + consecutiveFailuresByKind: { + transient: 2, + reauth_required: 0, + persistent: 0, + }, + failureThreshold: 3, + }, + ), + ); + const pre = html.match( + /]*data-slot="integration-diagnostics"[^>]*>([\s\S]*?)<\/pre>/, + )?.[1]; + expect(pre).toBeDefined(); + expect(pre).toContain("HealthLog"); + expect(pre).toContain("Oura"); + expect(pre).toContain( + "State: degraded / provider_errors since 2026-10-14 18:40 UTC", + ); + expect(pre).toContain("Last sync: 2026-10-14 18:12 UTC"); + expect(pre).toContain("Ledger: error_transient, failing parts: sleep"); + expect(pre).toContain("Failure streak: 2 of 3"); + expect(pre).toMatch(/2026-10-15 09:15 {2}sleep {2}transient +503/); + // The stored provider message, its token and the account never appear, + // neither in the block nor anywhere else on the card. + expect(html).not.toContain("token=abc"); + expect(html).not.toContain("user-42"); + expect(html).not.toContain("Oura error"); + }); +}); diff --git a/src/components/settings/integrations/__tests__/integration-health-copy.test.ts b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts new file mode 100644 index 000000000..abdef8e95 --- /dev/null +++ b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; + +import { locales, type Locale } from "@/lib/i18n/config"; +import { makeFormatters } from "@/lib/format-locale"; +import { + CONNECTION_HEALTH_REASONS, + CONNECTION_HEALTH_STATES, +} from "@/lib/integrations/connection-health"; + +import de from "../../../../../messages/de.json"; +import en from "../../../../../messages/en.json"; +import es from "../../../../../messages/es.json"; +import fr from "../../../../../messages/fr.json"; +import itMessages from "../../../../../messages/it.json"; +import ko from "../../../../../messages/ko.json"; +import pl from "../../../../../messages/pl.json"; + +import { + diagnosticsText, + formatHealthInstant, + healthBadgeLabel, + healthSentence, + namedItemList, +} from "../integration-health-copy"; +import { healthFixture } from "./health-fixture"; + +const CATALOGS: Record = { + de, + en, + es, + fr, + it: itMessages, + pl, + ko, +}; + +function translator(locale: Locale) { + return (key: string, params?: Record) => { + const value = key + .split(".") + .reduce( + (node, part) => (node as Record | undefined)?.[part], + CATALOGS[locale], + ); + if (typeof value !== "string") return key; + return value.replace(/\{(\w+)\}/g, (_, name: string) => + String(params?.[name] ?? `{${name}}`), + ); + }; +} + +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const fmt = makeFormatters("de", "Europe/Berlin", "AUTO", "AUTO"); +const t = translator("de"); + +describe("formatHealthInstant", () => { + it("reads today and yesterday with the time, older instants with the date", () => { + expect(formatHealthInstant("2026-10-15T05:12:00.000Z", t, fmt, NOW)).toBe( + "heute, 07:12", + ); + expect(formatHealthInstant("2026-10-14T16:40:00.000Z", t, fmt, NOW)).toBe( + "gestern, 18:40", + ); + expect(formatHealthInstant("2026-10-12T12:00:00.000Z", t, fmt, NOW)).toBe( + "12.10., 14:00", + ); + }); + + it("buckets the day in the reader's zone, not UTC", () => { + // 23:30 UTC on the 14th is already the 15th in Berlin. + expect(formatHealthInstant("2026-10-14T23:30:00.000Z", t, fmt, NOW)).toBe( + "heute, 01:30", + ); + }); +}); + +describe("namedItemList", () => { + it("names up to three types", () => { + expect( + namedItemList( + [ + { item: "spo2", cause: "plan" }, + { item: "vascularAge", cause: "plan" }, + { item: "ecg", cause: "plan" }, + ], + t, + "de", + ), + ).toBe("SpO2, Gefäßalter und EKG und Vorhofflimmern"); + }); + + it("names two and counts the rest beyond three", () => { + expect( + namedItemList( + ["spo2", "vascularAge", "ecg", "vo2Max", "weight"].map((item) => ({ + item, + cause: "plan" as const, + })), + t, + "de", + ), + ).toBe("SpO2, Gefäßalter und 3 weitere Werte"); + }); + + it("names the Withings activity scope gap", () => { + expect( + namedItemList( + [ + { item: "activity", cause: "permission" }, + { item: "sleep", cause: "permission" }, + ], + t, + "de", + ), + ).toBe("Aktivität und Schlaf"); + }); +}); + +describe("healthSentence", () => { + const opts = { provider: "Oura", locale: "de" as const, fmt, now: NOW }; + + it("uses the dated sentence when the server dates the condition", () => { + expect( + healthSentence( + healthFixture({ + state: "degraded", + reason: "no_new_data", + since: "2026-10-14T16:40:00.000Z", + }), + t, + opts, + ), + ).toBe("Seit gestern, 18:40 sind keine neuen Daten gekommen."); + }); + + it("falls back to an undated sentence rather than printing nothing", () => { + expect( + healthSentence( + healthFixture({ state: "degraded", reason: "provider_errors" }), + t, + opts, + ), + ).toBe( + "Oura antwortet gerade nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.", + ); + }); + + it("tells an Apple Health user to open the app", () => { + expect( + healthSentence( + healthFixture({ + state: "degraded", + reason: "no_new_data", + since: "2026-10-12T12:00:00.000Z", + }), + t, + { ...opts, provider: "Apple Health", appleHealth: true }, + ), + ).toBe( + "Seit 12.10., 14:00 sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.", + ); + }); + + it.each(locales)( + "has a sentence and a badge word for every state and reason in %s", + (locale) => { + const tl = translator(locale); + const f = makeFormatters(locale, "Europe/Berlin", "AUTO", "AUTO"); + for (const state of CONNECTION_HEALTH_STATES) { + const word = healthBadgeLabel(state, tl); + expect(word).not.toContain("settings."); + } + for (const reason of CONNECTION_HEALTH_REASONS) { + for (const since of [null, "2026-10-14T16:40:00.000Z"]) { + const sentence = healthSentence( + healthFixture({ + reason, + since, + unavailable: [ + { item: "spo2", cause: "plan" }, + { item: "activity", cause: "permission" }, + ], + }), + tl, + { provider: "Oura", locale, fmt: f, now: NOW }, + ); + expect(sentence, `${locale} ${reason}`).not.toMatch( + /settings\.|\{\w+\}| · | — | – /, + ); + expect(sentence.length).toBeGreaterThan(5); + } + } + }, + ); +}); + +describe("diagnosticsText", () => { + it("prints the draft's block from health alone", () => { + const text = diagnosticsText( + { + version: "1.42.4", + provider: "Oura", + health: healthFixture({ + state: "degraded", + reason: "provider_errors", + since: "2026-10-14T18:40:00.000Z", + lastSuccessAt: "2026-10-14T18:12:00.000Z", + lastDataAt: "2026-10-14T07:55:00.000Z", + unavailable: [{ item: "spo2", cause: "permission" }], + diagnostics: { + ledgerState: "error_transient", + failingLegs: ["sleep"], + failingSince: "2026-10-14T18:40:00.000Z", + lastAttemptAt: "2026-10-15T09:15:00.000Z", + verdict: "failing", + recentCalls: [ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T09:15:00.000Z", + errorCode: "503", + }, + { + endpoint: "readiness", + status: "ok", + at: "2026-10-15T08:15:00.000Z", + errorCode: null, + }, + ], + }, + }), + }, + t, + ); + expect(text.split("\n")).toEqual([ + "HealthLog 1.42.4 Oura", + "Zustand: degraded / provider_errors seit 2026-10-14 18:40 UTC", + "Letzte Synchronisierung: 2026-10-14 18:12 UTC", + "Letzte Daten: 2026-10-14 07:55 UTC", + "Ledger: error_transient, fehlende Teile: sleep", + "Letzte Aufrufe:", + " 2026-10-15 09:15 sleep transient 503", + " 2026-10-15 08:15 readiness ok", + "Nicht verfügbar: spo2 (Freigabe)", + ]); + }); + + it("leaves out what the server does not know instead of inventing it", () => { + const text = diagnosticsText( + { + version: "", + provider: "Apple Health", + health: healthFixture({ + lastSuccessAt: null, + lastDataAt: null, + diagnostics: { + ledgerState: null, + failingLegs: [], + failingSince: null, + lastAttemptAt: null, + verdict: "fresh", + recentCalls: [], + }, + }), + }, + t, + ); + expect(text.split("\n")).toEqual([ + "HealthLog Apple Health", + "Zustand: ok / data_arriving", + "Letzte Synchronisierung: keine", + "Letzte Daten: keine", + ]); + }); +}); diff --git a/src/components/settings/integrations/credentials-form.tsx b/src/components/settings/integrations/credentials-form.tsx new file mode 100644 index 000000000..c3ecb5be9 --- /dev/null +++ b/src/components/settings/integrations/credentials-form.tsx @@ -0,0 +1,175 @@ +"use client"; + +/** + * The bring-your-own OAuth app credentials form, one copy for every provider + * that takes a client id and secret (Withings, WHOOP, Fitbit, Google Health, + * Polar, Oura, Strava). It sits in the not-connected card before "Connect", + * and behind "Change credentials" in the menu once connected. + * + * Config fields, so no password manager is offered them (`autoComplete="off"` + * on both, the secret through `PasswordInput`). + */ + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, Save } from "lucide-react"; + +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +export function CredentialsForm({ + provider, + i18nPrefix, + saved, + secretPlaceholder, + statusQueryKey, +}: { + /** Route segment: PUT `/api//credentials`; also the field ids. */ + provider: string; + /** i18n base, e.g. `settings.withings`. */ + i18nPrefix: string; + /** A pair is stored already: the fields show the saved placeholder. */ + saved: boolean; + /** + * The provider ships a secret-specific saved placeholder + * (`…CredentialsSavedPlaceholderSecret`); the others reuse the id's. + */ + secretPlaceholder: boolean; + statusQueryKey: readonly unknown[]; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ + kind: "success" | "error"; + text: string; + } | null>(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + try { + const res = await apiFetchRaw(`/api/${provider}/credentials`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + }); + if (res.ok) { + setMsg({ kind: "success", text: t(`${i18nPrefix}CredentialsSaved`) }); + setClientId(""); + setClientSecret(""); + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + // The cards read the consolidated envelope; repaint it as well. + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + let text = t("settings.savingError"); + try { + const json = await res.json(); + if (json.error) text = json.error; + } catch { + // keep the generic message + } + setMsg({ kind: "error", text }); + } + } catch { + setMsg({ kind: "error", text: t("common.networkError") }); + } + setSaving(false); + } + + return ( +
    +

    + {t(`${i18nPrefix}Credentials`)} +

    +
    +
    + + setClientId(e.target.value)} + placeholder={ + saved + ? t(`${i18nPrefix}CredentialsSavedPlaceholder`) + : t(`${i18nPrefix}ClientId`) + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="next" + /> +
    +
    + + setClientSecret(e.target.value)} + placeholder={ + !saved + ? t(`${i18nPrefix}ClientSecret`) + : secretPlaceholder + ? t(`${i18nPrefix}CredentialsSavedPlaceholderSecret`) + : t(`${i18nPrefix}CredentialsSavedPlaceholder`) + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="done" + /> +
    +
    + {msg && ( +

    + {msg.text} +

    + )} + + + +
    + ); +} diff --git a/src/components/settings/integrations/integration-actions.tsx b/src/components/settings/integrations/integration-actions.tsx new file mode 100644 index 000000000..797813c35 --- /dev/null +++ b/src/components/settings/integrations/integration-actions.tsx @@ -0,0 +1,208 @@ +"use client"; + +/** + * The requests behind an integration card's menu, shared by every provider + * controller: sync now (optionally the whole history), test the connection, + * and the lines that report what happened. The card renders the lines in its + * body; the menu only starts the request. + */ + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, Loader2 } from "lucide-react"; + +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; +import { + runConnectionTest, + TestConnectionFailure, + type ConnectionTestResult, +} from "@/components/settings/test-connection-button"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; + +import { + readSyncOutcome, + useSyncOutcomeMessage, + type SyncOutcomeState, +} from "./sync-outcome"; + +export interface ProviderSync { + syncing: boolean; + result: SyncOutcomeState | null; + run: (fullSync: boolean) => void; +} + +/** + * Sync now, for every provider whose sync route answers the shared written + * outcome. The tone comes off what the run wrote, never off `res.ok`. + */ +export function useStandardSync({ + provider, + i18nPrefix, + acceptsFullSync, + fullSyncResult, + statusQueryKey, +}: { + /** Route segment: POST `/api//sync`. */ + provider: string; + i18nPrefix: string; + /** The route takes `{ fullSync }`; the others take no body. */ + acceptsFullSync: boolean; + /** A full sync has its own success sentence (`…FullSyncResult`). */ + fullSyncResult?: boolean; + statusQueryKey?: readonly unknown[]; +}): ProviderSync { + const { t } = useTranslations(); + const describeSyncOutcome = useSyncOutcomeMessage(); + const queryClient = useQueryClient(); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + + async function run(fullSync: boolean) { + setSyncing(true); + setSyncResult(null); + try { + const res = await apiFetchRaw( + `/api/${provider}/sync`, + acceptsFullSync + ? { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + } + : { method: "POST" }, + ); + const json = await res.json(); + const result = res.ok ? readSyncOutcome(json) : null; + if (result) { + setSyncResult({ + outcome: result.outcome, + message: describeSyncOutcome( + result, + fullSync && fullSyncResult + ? t(`${i18nPrefix}FullSyncResult`, { count: result.imported }) + : t(`${i18nPrefix}SyncResult`, { count: result.imported }), + ), + }); + void invalidateKeys(queryClient, measurementDependentKeys); + if (statusQueryKey) { + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + setSyncResult({ + outcome: "failed", + message: json?.error || t(`${i18nPrefix}SyncFailed`), + }); + } + } catch { + setSyncResult({ + outcome: "failed", + message: t(`${i18nPrefix}SyncFailed`), + }); + } finally { + setSyncing(false); + } + } + + return { + syncing, + result: syncResult, + run: (fullSync) => void run(fullSync), + }; +} + +/** "Test connection" from the menu; the result shows in the card body. */ +export function useConnectionTest(endpoint: string) { + const [testing, setTesting] = useState(false); + const [result, setResult] = useState(null); + async function run() { + setTesting(true); + setResult(null); + try { + setResult(await runConnectionTest(endpoint)); + } finally { + setTesting(false); + } + } + return { testing, result, run: () => void run() }; +} + +/** + * What the last menu action reported: a running sync or test, the sync's + * written outcome, the test's answer. Renders nothing when nothing ran. + */ +export function ActionResultLines({ + provider, + sync, + test, +}: { + provider: string; + sync?: Pick; + test?: ReturnType; +}) { + const { t } = useTranslations(); + + function describeError(errorCode: string): string { + const key = `settings.testConnection.errors.${errorCode}`; + const translated = t(key); + return translated === key + ? t("settings.testConnection.errors.generic") + : translated; + } + + const busy = sync?.syncing || test?.testing; + const syncResult = sync?.result ?? null; + if (!busy && !syncResult && !test?.result) return null; + + return ( +
    + {busy ? ( +

    +

    + ) : null} + {syncResult ? ( + + ) : null} + {test?.result?.kind === "ok" ? ( +

    +

    + ) : null} + {test?.result?.kind === "error" ? ( + + ) : null} +
    + ); +} diff --git a/src/components/settings/integrations/integration-card.tsx b/src/components/settings/integrations/integration-card.tsx new file mode 100644 index 000000000..451b563db --- /dev/null +++ b/src/components/settings/integrations/integration-card.tsx @@ -0,0 +1,603 @@ +"use client"; + +/** + * `` — the one card every connection in Settings renders. + * + * A card says three things: whether it works (a badge with one word), what + * that means (one sentence), and what the person can do (at most one button). + * Everything else is either diagnostics, collapsed under "Details for a bug + * report", or rarely needed, in the three-dot menu. + * + * The card renders the server's `health` and nothing else. It does not read a + * ledger state, a verdict, a token expiry or a failure counter, and it never + * decides a state of its own: the badge, the sentence and the button are each + * a projection of one `ConnectionHealth`. Per-provider controllers (OAuth, + * Nightscout, Apple Health) own the requests and hand the card their menu + * entries, their primary handlers and anything that belongs in the details. + * + * Anatomy, top to bottom (design standards §1, §12, §13): + * header `SettingsCardHeader`, the badge in its status slot + * sentence `text-sm` foreground, then one muted meta line + * body transient lines a controller adds (a sync result, a form) + * details collapsed, always present on a connected card + * actions `SettingsCardActions`, the last element: menu left, at most + * one primary right + */ + +import { useState, type ReactNode } from "react"; +import { + Check, + ChevronDown, + Copy, + ExternalLink, + Link2, + Loader2, + MoreHorizontal, + Play, + type LucideIcon, +} from "lucide-react"; + +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + ConnectionHealth, + ConnectionHealthState, +} from "@/lib/integrations/connection-health"; +import { cn } from "@/lib/utils"; + +import { + diagnosticsText, + formatHealthInstant, + healthBadgeLabel, + healthSentence, + unavailableItemLabel, +} from "./integration-health-copy"; + +/** The app version the build stamped; empty in a dev build. */ +const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ""; + +const BADGE_TONE: Record = { + ok: "border-success/30 bg-success/15 text-success", + limited: "border-info/30 bg-info/15 text-info", + degraded: "border-warning/30 bg-warning/15 text-warning", + action_required: "border-destructive/30 bg-destructive/15 text-destructive", +}; + +/** The status badge: a dot and one word. The word is the accessible name. */ +export function HealthBadge({ state }: { state: ConnectionHealthState }) { + const { t } = useTranslations(); + return ( + + + ); +} + +export interface IntegrationMenuAction { + /** Stable id; becomes `data-action` and the `-menu-` testid. */ + id: string; + label: string; + icon: LucideIcon; + onSelect?: () => void; + /** Navigates instead of acting. External links open in a new tab. */ + href?: string; + external?: boolean; + disabled?: boolean; + destructive?: boolean; + /** Ask first; the action runs from the dialog's confirm button. */ + confirm?: { title: string; body: string; confirmLabel: string }; +} + +export interface IntegrationPrimaryHandlers { + onReconnect?: () => void; + onResume?: () => void; + pending?: boolean; + disabled?: boolean; +} + +export interface IntegrationCardProps { + /** Connection key; drives `data-integration` and the testids. */ + provider: string; + /** Provider name used inside sentences and the diagnostics. */ + providerName: string; + icon: LucideIcon; + title?: ReactNode; + titleAccessory?: ReactNode; + /** One meta sentence with the setup-guide link; shown while not connected. */ + description?: ReactNode; + /** The status has not loaded yet. */ + loading?: boolean; + connected: boolean; + /** The server-resolved health; null when the server has none to give. */ + health: ConnectionHealth | null; + /** Server fact: older history is still loading in the background. */ + historyLoading?: boolean; + /** Apple Health's "open HealthLog on your iPhone" sentence variant. */ + appleHealth?: boolean; + primary?: IntegrationPrimaryHandlers; + menu?: IntegrationMenuAction[]; + /** Provider extras for the details section (lists, delivery facts). */ + details?: ReactNode; + /** Extra redacted lines for the copied diagnostics. */ + diagnosticsExtra?: readonly string[]; + /** Lines below the sentence: results, errors, an opened form. */ + children?: ReactNode; + /** Not connected: the forms and guides that come before connecting. */ + setup?: ReactNode; + /** Not connected: the one primary button, usually "Connect". */ + connectAction?: ReactNode; + /** Overrides the `-card` testid (the read-only fallback row). */ + testId?: string; +} + +export function IntegrationCard({ + provider, + providerName, + icon, + title, + titleAccessory, + description, + loading = false, + connected, + health, + historyLoading, + appleHealth, + primary, + menu = [], + details, + diagnosticsExtra, + children, + setup, + connectAction, + testId, +}: IntegrationCardProps) { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + const [detailsOpen, setDetailsOpen] = useState(false); + const detailsId = `${provider}-integration-details`; + + const cardState = loading + ? "loading" + : !connected + ? "not_connected" + : (health?.state ?? "unknown"); + + const header = ( + + ) : null + } + /> + ); + + const shell = (body: ReactNode) => ( + + {header} + {body} + + ); + + if (loading) { + // A loading state paints the card and its header (§13), so the page does + // not move when the status lands. + return shell( +
    + + +
    , + ); + } + + if (!connected) { + return shell( + <> +

    + {t("settings.integrationCard.notConnected")} +

    + {setup} + {children} + {connectAction ? ( + + {connectAction} + + ) : null} + , + ); + } + + const sentence = health + ? healthSentence(health, t, { + provider: providerName, + historyLoading, + appleHealth, + locale, + fmt, + }) + : null; + const lastData = health + ? health.lastDataAt + ? t("settings.integrationCard.lastData", { + when: formatHealthInstant(health.lastDataAt, t, fmt), + }) + : t("settings.integrationCard.lastDataNone") + : null; + + const action = health?.action ?? null; + const primaryButton = + action === "reconnect" && primary?.onReconnect ? ( + + ) : action === "resume" && primary?.onResume ? ( + + ) : null; + + return shell( + <> + {sentence || lastData ? ( +
    + {sentence ? ( +

    + {sentence} +

    + ) : null} + {lastData ? ( +

    + {lastData} +

    + ) : null} +
    + ) : null} + {children} + {health ? ( + + unavailableItemLabel(u.item, t), + )} + > + {details} + + ) : details ? ( +
    {details}
    + ) : null} + {health || menu.length > 0 || primaryButton ? ( + + {health ? ( + + ) : null} +
    + {menu.length > 0 ? ( + + ) : null} + {primaryButton} +
    +
    + ) : null} + , + ); +} + +function PrimaryButton({ + action, + icon: Icon, + label, + onClick, + pending, + disabled, + provider, +}: { + action: "reconnect" | "resume"; + icon: LucideIcon; + label: string; + onClick: () => void; + pending?: boolean; + disabled?: boolean; + provider: string; +}) { + return ( + + ); +} + +/** + * The rare actions. A destructive or heavy entry asks first through the same + * confirmation the card used before; the dialog is controlled here because a + * menu item closes its menu, and with it any dialog nested inside. + */ +function IntegrationMenu({ + provider, + providerName, + items, +}: { + provider: string; + providerName: string; + items: IntegrationMenuAction[]; +}) { + const { t } = useTranslations(); + const [confirming, setConfirming] = useState( + null, + ); + + return ( + <> + + + + + + {items.map((item) => { + const Icon = item.icon; + const common = { + "data-slot": "integration-menu-item", + "data-action": item.id, + "data-testid": `${provider}-menu-${item.id}`, + disabled: item.disabled, + variant: item.destructive + ? ("destructive" as const) + : ("default" as const), + }; + if (item.href) { + return ( + + + + + ); + } + return ( + { + if (item.confirm) setConfirming(item); + else item.onSelect?.(); + }} + > + + ); + })} + + + {confirming?.confirm ? ( + { + if (!open) setConfirming(null); + }} + title={confirming.confirm.title} + body={confirming.confirm.body} + confirmLabel={confirming.confirm.confirmLabel} + destructive={confirming.destructive} + slot={`${provider}-${confirming.id}`} + onConfirm={() => { + const run = confirming.onSelect; + setConfirming(null); + run?.(); + }} + /> + ) : null} + + ); +} + +/** + * "Details for a bug report": collapsed by default and present in every state, + * so a report always looks the same. Its toggle sits at the start of the + * action row and the panel opens above that row, which stays the last thing in + * the card. The monospace block is exactly the text the copy button places on + * the clipboard. The panel is always in the markup and `hidden` while closed, + * so what a report would contain is inspectable without opening it. + */ +function DetailsPanel({ + id, + open, + text, + unavailable, + children, +}: { + id: string; + open: boolean; + text: string; + unavailable: string[]; + children?: ReactNode; +}) { + const { t } = useTranslations(); + const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle"); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(text); + setCopy("copied"); + window.setTimeout(() => setCopy("idle"), 2000); + } catch { + setCopy("failed"); + } + } + + return ( + + ); +} diff --git a/src/components/settings/integrations/integration-health-copy.ts b/src/components/settings/integrations/integration-health-copy.ts new file mode 100644 index 000000000..f9340dd4e --- /dev/null +++ b/src/components/settings/integrations/integration-health-copy.ts @@ -0,0 +1,358 @@ +/** + * The words an integration card says about a connection's health. + * + * The server resolves one `ConnectionHealth` per connection and the card only + * renders it, so everything here is a projection: a badge word per state, one + * sentence per reason, a relative instant, and the plain-text diagnostics a + * person can paste into a bug report. Nothing in this module decides a state. + * + * Pure (a translator and the locale formatters are passed in) so every + * sentence and the copied diagnostics are unit-testable without a renderer. + */ +import type { + ConnectionHealth, + ConnectionHealthState, + UnavailableItem, +} from "@/lib/integrations/connection-health"; +import { pluralKey } from "@/lib/i18n/plural"; +import type { Locale } from "@/lib/i18n/config"; + +type Translate = ( + key: string, + params?: Record, +) => string; + +interface InstantFormatters { + dateShortSmart: (value: Date) => string; + time: (value: Date) => string; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The one word in the status badge. */ +export function healthBadgeLabel( + state: ConnectionHealthState, + t: Translate, +): string { + switch (state) { + case "ok": + return t("settings.integrationCard.state.ok"); + case "limited": + return t("settings.integrationCard.state.limited"); + case "degraded": + return t("settings.integrationCard.state.degraded"); + case "action_required": + return t("settings.integrationCard.state.actionRequired"); + } +} + +/** + * An instant as a person reads it: "today, 07:12", "yesterday, 18:40", or the + * date with its time. The day comparison goes through the same zone-aware + * formatter that prints the date, so the boundary and the printed day can + * never disagree. + */ +export function formatHealthInstant( + iso: string, + t: Translate, + fmt: InstantFormatters, + now: Date = new Date(), +): string { + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return ""; + const day = fmt.dateShortSmart(at); + const time = fmt.time(at); + if (day === fmt.dateShortSmart(now)) { + return t("settings.integrationCard.when.today", { time }); + } + if (day === fmt.dateShortSmart(new Date(now.getTime() - DAY_MS))) { + return t("settings.integrationCard.when.yesterday", { time }); + } + return t("settings.integrationCard.when.date", { date: day, time }); +} + +/** + * A readable name for a data type the provider withholds. Written as literal + * keys so the i18n coverage guards see each one; an unknown key degrades to a + * readable form of itself rather than leaking `snake_case`. + */ +export function unavailableItemLabel(item: string, t: Translate): string { + switch (item) { + case "spo2": + return t("settings.integrations.withings.plan.items.spo2"); + case "vascularAge": + return t("settings.integrations.withings.plan.items.vascularAge"); + case "pulseWaveVelocity": + return t("settings.integrations.withings.plan.items.pulseWaveVelocity"); + case "vo2Max": + return t("settings.integrations.withings.plan.items.vo2Max"); + case "visceralFat": + return t("settings.integrations.withings.plan.items.visceralFat"); + case "skinTemperature": + return t("settings.integrations.withings.plan.items.skinTemperature"); + case "ecg": + return t("settings.integrations.withings.plan.items.ecg"); + case "sleepScore": + return t("settings.integrations.withings.plan.items.sleepScore"); + case "sleepHrv": + return t("settings.integrations.withings.plan.items.sleepHrv"); + case "sleepHeartRate": + return t("settings.integrations.withings.plan.items.sleepHeartRate"); + case "sleepRespiratoryRate": + return t( + "settings.integrations.withings.plan.items.sleepRespiratoryRate", + ); + case "sleepSpo2": + return t("settings.integrations.withings.plan.items.sleepSpo2"); + case "bodyTemperature": + return t("settings.integrations.withings.plan.items.bodyTemperature"); + case "bodyFat": + return t("settings.integrations.withings.plan.items.bodyFat"); + case "fatMass": + return t("settings.integrations.withings.plan.items.fatMass"); + case "fatFreeMass": + return t("settings.integrations.withings.plan.items.fatFreeMass"); + case "muscleMass": + return t("settings.integrations.withings.plan.items.muscleMass"); + case "bodyWater": + return t("settings.integrations.withings.plan.items.bodyWater"); + case "boneMass": + return t("settings.integrations.withings.plan.items.boneMass"); + case "heartRate": + return t("settings.integrations.withings.plan.items.heartRate"); + case "bloodPressure": + return t("settings.integrations.withings.plan.items.bloodPressure"); + case "weight": + return t("settings.integrations.withings.plan.items.weight"); + case "vo2max": + return t("settings.integrations.oura.scope.items.vo2max"); + case "cardio_age": + return t("settings.integrations.oura.scope.items.cardio_age"); + case "resilience": + return t("settings.integrations.oura.scope.items.resilience"); + case "activity": + return t("settings.integrationCard.items.activity"); + case "sleep": + return t("settings.integrationCard.items.sleep"); + default: { + const words = item + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .toLowerCase(); + return words; + } + } +} + +/** + * At most three names in a sentence. Up to three types are all named; more + * than three name the first two and count the rest ("SpO2, vascular age and + * 3 more"), and the full list lives in the details. + */ +export function namedItemList( + items: readonly UnavailableItem[], + t: Translate, + locale: Locale, +): string { + const labels = items.map((u) => unavailableItemLabel(u.item, t)); + const parts = + labels.length <= 3 + ? labels + : [ + ...labels.slice(0, 2), + t( + pluralKey( + "settings.integrationCard.moreValues", + labels.length - 2, + locale, + ), + { count: labels.length - 2 }, + ), + ]; + try { + return new Intl.ListFormat(locale, { + style: "long", + type: "conjunction", + }).format(parts); + } catch { + return parts.join(", "); + } +} + +export interface HealthSentenceOptions { + /** Provider name as the person knows it ("Oura", "Withings"). */ + provider: string; + /** Older history is still loading (a server fact, e.g. backfill pending). */ + historyLoading?: boolean; + /** Apple Health: data only moves when the iPhone app runs. */ + appleHealth?: boolean; + locale: Locale; + fmt: InstantFormatters; + now?: Date; +} + +/** The one sentence a card says under its header. */ +export function healthSentence( + health: ConnectionHealth, + t: Translate, + options: HealthSentenceOptions, +): string { + const { provider, locale, fmt } = options; + const since = health.since + ? formatHealthInstant(health.since, t, fmt, options.now) + : null; + const ofCause = (cause: UnavailableItem["cause"]) => + namedItemList( + health.unavailable.filter((u) => u.cause === cause), + t, + locale, + ); + + switch (health.reason) { + case "data_arriving": + return options.historyLoading + ? t("settings.integrationCard.reason.dataArrivingHistory") + : t("settings.integrationCard.reason.dataArriving"); + case "first_sync_pending": + return t("settings.integrationCard.reason.firstSyncPending"); + case "plan_limited": + return t("settings.integrationCard.reason.planLimited", { + provider, + list: ofCause("plan"), + }); + case "permission_limited": + return t("settings.integrationCard.reason.permissionLimited", { + list: ofCause("permission"), + }); + case "provider_errors": + return since + ? t("settings.integrationCard.reason.providerErrors", { + provider, + since, + }) + : t("settings.integrationCard.reason.providerErrorsNoSince", { + provider, + }); + case "sync_stalled": + return since + ? t("settings.integrationCard.reason.syncStalled", { since }) + : t("settings.integrationCard.reason.syncStalledNoSince"); + case "no_new_data": + if (options.appleHealth) { + return since + ? t("settings.integrationCard.reason.noNewDataApple", { since }) + : t("settings.integrationCard.reason.noNewDataAppleNoSince"); + } + return since + ? t("settings.integrationCard.reason.noNewData", { since }) + : t("settings.integrationCard.reason.noNewDataNoSince"); + case "unexpected_silence": + return since + ? t("settings.integrationCard.reason.unexpectedSilence", { + provider, + since, + }) + : t("settings.integrationCard.reason.unexpectedSilenceNoSince", { + provider, + }); + case "reauth_required": + return t("settings.integrationCard.reason.reauthRequired", { provider }); + case "paused_after_errors": + return since + ? t("settings.integrationCard.reason.pausedAfterErrors", { + provider, + since, + }) + : t("settings.integrationCard.reason.pausedAfterErrorsNoSince", { + provider, + }); + } +} + +/** `2026-10-14T18:40:12.000Z` → `2026-10-14 18:40 UTC`. */ +function utc(iso: string | null): string | null { + if (!iso) return null; + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return null; + return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +export interface DiagnosticsInput { + /** The running app version; empty when the build did not stamp one. */ + version: string; + provider: string; + health: ConnectionHealth; + /** Extra lines the card adds, already redacted (a failure streak count). */ + extra?: readonly string[]; +} + +/** + * The plain-text diagnostics, exactly as the details block shows them and the + * copy button places them on the clipboard. + * + * Built only from `health`, whose diagnostics the server already reduced to + * leg names, outcomes, instants and short error codes. No measured value, no + * token, no account id and no provider error message can reach this text, + * because none of them is on the input. + */ +export function diagnosticsText(input: DiagnosticsInput, t: Translate): string { + const { health } = input; + const none = t("settings.integrationCard.details.none"); + const lines: string[] = []; + lines.push( + `${["HealthLog", input.version].filter(Boolean).join(" ")} ${input.provider}`, + ); + const since = utc(health.since); + lines.push( + `${t("settings.integrationCard.details.state")}: ${health.state} / ${health.reason}${ + since ? ` ${t("settings.integrationCard.details.since")} ${since}` : "" + }`, + ); + lines.push( + `${t("settings.integrationCard.details.lastSync")}: ${utc(health.lastSuccessAt) ?? none}`, + ); + lines.push( + `${t("settings.integrationCard.details.lastData")}: ${utc(health.lastDataAt) ?? none}`, + ); + const d = health.diagnostics; + if (d.ledgerState || d.failingLegs.length > 0) { + const legs = + d.failingLegs.length > 0 + ? `, ${t("settings.integrationCard.details.failingParts")}: ${d.failingLegs.join(", ")}` + : ""; + lines.push(`Ledger: ${d.ledgerState ?? none}${legs}`); + } + for (const line of input.extra ?? []) lines.push(line); + if (d.recentCalls.length > 0) { + lines.push(`${t("settings.integrationCard.details.recentCalls")}:`); + const width = Math.max(...d.recentCalls.map((c) => c.endpoint.length)); + for (const call of d.recentCalls) { + const at = (utc(call.at) ?? "").replace(" UTC", ""); + const row = [ + at, + call.endpoint.padEnd(width), + call.status.padEnd(15), + call.errorCode ?? "", + ] + .join(" ") + .trimEnd(); + lines.push(` ${row}`); + } + } + if (health.unavailable.length > 0) { + const items = health.unavailable + .map( + (u) => + `${u.item} (${ + u.cause === "plan" + ? t("settings.integrationCard.details.causePlan") + : t("settings.integrationCard.details.causePermission") + })`, + ) + .join(", "); + lines.push( + `${t("settings.integrationCard.details.unavailable")}: ${items}`, + ); + } + return lines.join("\n"); +} diff --git a/src/components/settings/integrations/metric-freshness-list.tsx b/src/components/settings/integrations/metric-freshness-list.tsx new file mode 100644 index 000000000..b25df5130 --- /dev/null +++ b/src/components/settings/integrations/metric-freshness-list.tsx @@ -0,0 +1,162 @@ +"use client"; + +/** + * Per-metric freshness, rendered. + * + * The server has computed a last-seen timestamp per `(source, type)` on every + * status call since v1.27.30 and nothing rendered it. That is the one signal + * that separates "the connection is broken" from "one pipe inside a healthy + * connection is dead" — a revoked HealthKit permission, a per-collection 403, + * an upstream that quietly stopped returning one data type. The integration + * pill cannot express it, because the integration itself is fine. + * + * It lives in the card's collapsed details: a Withings or Google Health user + * carries 15-25 types and an always-open list would bury the card. The summary + * line above the rows still names how many types have gone quiet. + * + * Honest absence is structural: the server only reports `(source, type)` pairs + * that have rows, so a type a provider never delivered simply is not here. No + * "expected but missing" row is invented. + */ + +import type { ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; + +import { formatRelative } from "@/components/settings/integration-status-pill"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; +import { useTranslations } from "@/lib/i18n/context"; +import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict"; +import { cn } from "@/lib/utils"; + +/** The pseudo-type carrying a source's newest workout. */ +const WORKOUT_TYPE = "WORKOUTS"; + +/** + * Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS` + * is a plain record, not enum-complete, so a newly-added type must degrade to + * "Walking asymmetry" rather than leaking `WALKING_ASYMMETRY` at the user. + */ +function humanise(type: string): string { + const words = type.toLowerCase().split("_").join(" "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +export function MetricFreshnessList< + Entry extends MetricFreshnessEntry = MetricFreshnessEntry, +>({ + entries, + now, + title, + detail, +}: { + entries: Entry[] | undefined; + /** 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(); + + if (!entries?.length) return null; + + const reference = now ?? new Date(); + const label = (type: string): string => { + if (type === WORKOUT_TYPE) + return t("settings.integrationFreshness.workouts"); + const key = MEASUREMENT_TYPE_LABEL_KEYS[type]; + return key ? t(key) : humanise(type); + }; + const relative = (lastSeenAt: string): string => + formatRelative( + Math.max(0, reference.getTime() - new Date(lastSeenAt).getTime()), + t, + ); + + // Dead pipes surface without scrolling; everything else stays alphabetical. + const sorted = [...entries].sort( + (a, b) => + Number(b.stale) - Number(a.stale) || + label(a.type).localeCompare(label(b.type)), + ); + const staleCount = entries.filter((entry) => entry.stale).length; + const newest = entries.reduce((max, entry) => + entry.lastSeenAt > max.lastSeenAt ? entry : max, + ); + + return ( +
    +

    + + {title ?? t("settings.integrationFreshness.title")} + + + {t("settings.integrationFreshness.summary", { + count: entries.length, + relative: relative(newest.lastSeenAt), + })} + {staleCount > 0 ? ( + + {", "} + {t("settings.integrationFreshness.quiet", { count: staleCount })} + + ) : null} + +

    + +
      + {sorted.map((entry) => { + const extra = detail?.(entry); + return ( +
    • +
      + + {entry.stale ? ( + + +
      + {extra ? ( +
      + {extra} +
      + ) : null} +
    • + ); + })} +
    +
    + ); +} diff --git a/src/components/settings/integrations/oauth-integration-card.tsx b/src/components/settings/integrations/oauth-integration-card.tsx new file mode 100644 index 000000000..0d9d653f1 --- /dev/null +++ b/src/components/settings/integrations/oauth-integration-card.tsx @@ -0,0 +1,539 @@ +"use client"; + +/** + * The controller behind every OAuth integration card: Withings, WHOOP, + * Fitbit, Google Health, Polar, Oura and Strava. + * + * It owns the requests (connect, resume, sync, test, disconnect, the BYO + * credentials form) and hands `` what the person can do. The + * card decides nothing about health: it renders the server's `health` field. + * What used to be per-provider banners (the Withings plan note, the Oura scope + * note, the Withings activity-scope reconnect, the Google Health re-consent, + * the paused pill with its resume button) is now the sentence plus the one + * primary button that `health.action` names. + */ + +import { useState, type ReactNode } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Activity, + ArrowRight, + BookOpen, + CircleDot, + ExternalLink, + HeartPulse, + History, + KeyRound, + Link2, + Plug, + RefreshCw, + Unlink, + Watch, + type LucideIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; +import { loadDocument } from "@/lib/navigation/load-document"; +import { WITHINGS_API_PLANS_URL } from "@/lib/withings/plan-features"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; + +import { CredentialsForm } from "./credentials-form"; +import { + ActionResultLines, + useConnectionTest, + useStandardSync, + type ProviderSync, +} from "./integration-actions"; +import { + IntegrationCard, + type IntegrationMenuAction, +} from "./integration-card"; +import { MetricFreshnessList } from "./metric-freshness-list"; +import { + CallbackMismatchNotice, + IntegrationCardDescription, + IntegrationRedirectGuide, + integrationDocsHref, + type IntegrationDocsProvider, +} from "./setup-guide-link"; +import { failureStreakFor, type IntegrationStatusViewModel } from "./shared"; + +export type OAuthIntegrationKey = + | "withings" + | "whoop" + | "fitbit" + | "google-health" + | "polar" + | "oura" + | "strava"; + +export interface OAuthIntegrationConfig { + /** Connection key; also the `/api//…` route segment. */ + provider: OAuthIntegrationKey; + /** i18n base, e.g. `settings.withings`. */ + i18nPrefix: string; + icon: LucideIcon; + docs: IntegrationDocsProvider; + /** Where this provider's data surfaces. */ + dataHref: string; + /** The sync route takes `{ fullSync }` and the menu offers the full reload. */ + fullSync: boolean; + /** A full sync reports with its own sentence (`…FullSyncResult`). */ + fullSyncResult?: boolean; + /** + * `required`: the person must store their own client id/secret before + * connecting (`configured`). `optional`: a shared app may exist on the + * server (`available`), and the person's own pair is an alternative. + */ + credentials: "required" | "optional"; + /** The provider ships a secret-specific saved placeholder. */ + secretPlaceholder: boolean; + /** Show the callback-URL guide before credentials are stored. */ + redirectGuide: boolean; + /** + * POST endpoint that clears a pause. Null: the grant itself has to be + * re-issued, so "Resume" re-runs connect, as it always did for these. + */ + resumeEndpoint: string | null; + testEndpoint: string; + statusQueryKey: () => readonly unknown[]; +} + +export const OAUTH_INTEGRATIONS: Record< + OAuthIntegrationKey, + OAuthIntegrationConfig +> = { + withings: { + provider: "withings", + i18nPrefix: "settings.withings", + icon: Link2, + docs: "withings", + dataHref: "/measurements", + fullSync: true, + fullSyncResult: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: true, + resumeEndpoint: "/api/integrations/withings/resume", + testEndpoint: "/api/integrations/withings/test", + statusQueryKey: queryKeys.withings, + }, + whoop: { + provider: "whoop", + i18nPrefix: "settings.whoop", + icon: Activity, + docs: "whoop", + dataHref: "/insights/recovery", + fullSync: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: true, + resumeEndpoint: "/api/integrations/whoop/resume", + testEndpoint: "/api/integrations/whoop/test", + statusQueryKey: queryKeys.whoop, + }, + fitbit: { + provider: "fitbit", + i18nPrefix: "settings.fitbit", + icon: HeartPulse, + docs: "fitbit", + dataHref: "/insights/sleep", + fullSync: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: true, + resumeEndpoint: "/api/integrations/fitbit/resume", + testEndpoint: "/api/integrations/fitbit/test", + statusQueryKey: queryKeys.fitbit, + }, + "google-health": { + provider: "google-health", + i18nPrefix: "settings.googleHealth", + icon: Watch, + docs: "google-health", + dataHref: "/insights/steps", + fullSync: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: false, + resumeEndpoint: "/api/integrations/google-health/resume", + testEndpoint: "/api/integrations/google-health/test", + statusQueryKey: queryKeys.googleHealth, + }, + polar: { + provider: "polar", + i18nPrefix: "settings.polar", + icon: Watch, + docs: "polar", + dataHref: "/insights/sleep", + fullSync: false, + credentials: "optional", + secretPlaceholder: false, + redirectGuide: true, + resumeEndpoint: null, + testEndpoint: "/api/polar/test", + statusQueryKey: queryKeys.polar, + }, + oura: { + provider: "oura", + i18nPrefix: "settings.oura", + icon: CircleDot, + docs: "oura", + dataHref: "/insights/sleep", + fullSync: false, + credentials: "optional", + secretPlaceholder: false, + redirectGuide: true, + resumeEndpoint: null, + testEndpoint: "/api/oura/test", + statusQueryKey: queryKeys.oura, + }, + strava: { + provider: "strava", + i18nPrefix: "settings.strava", + icon: Activity, + docs: "strava", + dataHref: "/insights/workouts", + fullSync: false, + credentials: "optional", + secretPlaceholder: false, + redirectGuide: true, + resumeEndpoint: null, + testEndpoint: "/api/strava/test", + statusQueryKey: queryKeys.strava, + }, +}; + +/** The standard controller: a provider whose sync answers in one response. */ +export function OAuthIntegrationCard(props: { + config: OAuthIntegrationConfig; + viewModel: IntegrationStatusViewModel | undefined; + callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey]; + icon?: LucideIcon; + titleAccessory?: ReactNode; +}) { + const { config } = props; + const sync = useStandardSync({ + provider: config.provider, + i18nPrefix: config.i18nPrefix, + acceptsFullSync: config.fullSync, + fullSyncResult: config.fullSyncResult, + statusQueryKey: config.statusQueryKey(), + }); + return ; +} + +/** + * The view half, so a provider with its own sync choreography (Google Health + * polls for completion) can hand in its own `sync` and keep everything else. + */ +export function OAuthIntegrationCardView({ + config, + viewModel, + callbackUrl, + icon, + titleAccessory, + sync, + syncLabel, + syncDisabled, + fullSyncDisabled, + syncResults, + details, +}: { + config: OAuthIntegrationConfig; + viewModel: IntegrationStatusViewModel | undefined; + callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey]; + icon?: LucideIcon; + titleAccessory?: ReactNode; + sync: ProviderSync; + /** Overrides the sync item's label (Google Health's "check status"). */ + syncLabel?: string; + syncDisabled?: boolean; + fullSyncDisabled?: boolean; + /** Replaces the standard sync result line. */ + syncResults?: ReactNode; + /** Provider extras for the details section. */ + details?: ReactNode; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { provider, i18nPrefix } = config; + const statusQueryKey = config.statusQueryKey(); + const test = useConnectionTest(config.testEndpoint); + const [editingCredentials, setEditingCredentials] = useState(false); + + const providerName = t(i18nPrefix); + const connected = viewModel?.connected ?? false; + const configured = + config.credentials === "required" + ? (viewModel?.configured ?? false) + : (viewModel?.available ?? false); + const ownCredentials = + config.credentials === "required" + ? (viewModel?.configured ?? false) + : (viewModel?.hasOwnCredentials ?? false); + + function invalidateStatus() { + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } + + function connect() { + void invalidateKeys(queryClient, measurementDependentKeys); + loadDocument(`/api/${provider}/connect`); + } + + const disconnect = useMutation({ + mutationFn: async () => { + await apiPost(`/api/${provider}/disconnect`); + }, + onSuccess: invalidateStatus, + }); + + const resume = useMutation({ + mutationFn: async () => { + if (!config.resumeEndpoint) return null; + return apiPost<{ resumed: boolean; wasParked: boolean }>( + config.resumeEndpoint, + ); + }, + onSuccess: invalidateStatus, + }); + + const credentialsForm = ( + + ); + + const description = ( + + ); + + if (!connected) { + return ( + + {config.redirectGuide && !ownCredentials ? ( + + ) : null} + {credentialsForm} + {!configured ? ( +

    + {config.credentials === "required" + ? t(`${i18nPrefix}NoCredentials`) + : t(`${i18nPrefix}Unavailable`)} +

    + ) : null} + +
    + } + connectAction={ + + } + /> + ); + } + + const busy = sync.syncing; + const menu: IntegrationMenuAction[] = [ + { + id: "sync", + label: syncLabel ?? t(`${i18nPrefix}Sync`), + icon: RefreshCw, + disabled: busy || syncDisabled, + onSelect: () => sync.run(false), + }, + ...(config.fullSync + ? [ + { + id: "full-sync", + label: t(`${i18nPrefix}FullSync`), + icon: History, + disabled: busy || fullSyncDisabled, + onSelect: () => sync.run(true), + confirm: { + title: t(`${i18nPrefix}FullSyncTitle`), + body: t(`${i18nPrefix}FullSyncDescription`), + confirmLabel: t(`${i18nPrefix}Synchronize`), + }, + } satisfies IntegrationMenuAction, + ] + : []), + { + id: "test", + label: t("settings.testConnection.test"), + icon: Plug, + disabled: test.testing, + onSelect: test.run, + }, + { + id: "view-data", + label: t(`${i18nPrefix}ViewData`), + icon: ArrowRight, + href: config.dataHref, + }, + { + id: "credentials", + label: t("settings.integrationCard.menu.credentials"), + icon: KeyRound, + onSelect: () => setEditingCredentials((open) => !open), + }, + { + id: "setup-guide", + label: t("settings.integrationSetupGuide"), + icon: BookOpen, + href: integrationDocsHref(config.docs), + external: true, + }, + { + id: "disconnect", + label: t(`${i18nPrefix}Disconnect`), + icon: Unlink, + destructive: true, + onSelect: () => disconnect.mutate(), + confirm: { + title: t(`${i18nPrefix}DisconnectTitle`), + body: t(`${i18nPrefix}DisconnectDescription`), + confirmLabel: t(`${i18nPrefix}Disconnect`), + }, + }, + ]; + + const streak = failureStreakFor(viewModel); + // Withings' paid API plan withholds some readings; the page that explains + // the plans stays one tap away in the details. + const planLimited = + provider === "withings" && + (viewModel?.health?.unavailable.some((u) => u.cause === "plan") ?? false); + + return ( + resume.mutate() : connect, + pending: resume.isPending, + disabled: config.credentials === "optional" && !configured, + }} + menu={menu} + diagnosticsExtra={ + streak + ? [ + t("settings.integrationCard.details.failureStreak", { + count: streak.count, + threshold: streak.threshold, + }), + ] + : undefined + } + details={ + details || planLimited || viewModel?.metricFreshness?.length ? ( + <> + {planLimited ? ( + + {t("settings.integrations.withings.plan.link")} + + ) : null} + {details} + + + ) : undefined + } + > + {syncResults ?? ( + + )} + {syncResults ? ( + + ) : null} + {resume.isError ? ( +

    + {t("settings.integrationPill.resumeError")} +

    + ) : null} + {resume.isSuccess && resume.data?.wasParked ? ( +

    + {t("settings.integrationPill.resumeSuccess")} +

    + ) : null} + {editingCredentials ? credentialsForm : null} + +
    + ); +} diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 9919a20c4..44862fc08 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -105,7 +105,7 @@ const syncHealth = z // Mirrors `ConnectionHealth` in `src/lib/integrations/connection-health.ts`: // the one server-resolved state per connection every client renders. -const connectionHealth = z +export const connectionHealth = z .object({ state: z .enum(CONNECTION_HEALTH_STATES) diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts index d8622e60f..fc7dde72d 100644 --- a/src/lib/openapi/routes/record-settings.ts +++ b/src/lib/openapi/routes/record-settings.ts @@ -25,6 +25,7 @@ import { z } from "zod/v4"; import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings"; +import { connectionHealth } from "./integrations"; import { dataEnvelope, errorEnvelope, @@ -96,6 +97,11 @@ const recordSettingsIntegrationsResponse = z ]), lastSuccessAt: z.iso.datetime({ offset: true }).nullable(), lastAttemptAt: z.iso.datetime({ offset: true }).nullable(), + health: connectionHealth + .nullable() + .describe( + "The same server-resolved connection state the owner's own envelope carries, null while the provider is not connected. The web renders this; `state` stays for one transition release.", + ), }), ) .describe( @@ -105,7 +111,7 @@ const recordSettingsIntegrationsResponse = z .meta({ id: "RecordSettingsIntegrationsResponse", description: - "Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.", + "Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.", }); // ── The six family DTOs ────────────────────────────────────────────── @@ -353,7 +359,7 @@ export const recordSettingsPaths: NonNullable = { tags: ["Account sharing"], summary: "Read the managed record's integration connection states", description: - "Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.", + "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.", responses: { ...recordRefusal(), "200": { diff --git a/tests/integration/record-settings-integrations-health.test.ts b/tests/integration/record-settings-integrations-health.test.ts new file mode 100644 index 000000000..bbd8a61c6 --- /dev/null +++ b/tests/integration/record-settings-integrations-health.test.ts @@ -0,0 +1,125 @@ +/** + * A guardian reads the managed record's connections through the same + * server-resolved `health` the record's owner would see, and only the + * record's: a connection on the guardian's own account must not appear on + * the record, and the record's connection must answer from the record's rows. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +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), +})); + +interface ManagedIntegration { + integration: string; + state: string; + health: { state: string; reason: string } | null; +} + +async function readIntegrations(): Promise<{ + recordId: string; + integrations: ManagedIntegration[]; +}> { + const { GET } = await import("@/app/api/record-settings/integrations/route"); + const res = await GET(); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("GET /api/record-settings/integrations", () => { + it("publishes the record's own health per provider, never the guardian's", async () => { + const prisma = getPrismaClient(); + const guardian = await prisma.user.create({ + data: { + username: "health-guardian", + email: "health-guardian@example.test", + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + onboardingCompletedAt: new Date(), + // The guardian's own Polar: must not surface on the record. + polarAccessTokenEncrypted: "not-a-real-token", + }, + }); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + gender: null, + }); + // The record's own Oura, synced once. + await prisma.user.update({ + where: { id: profile.id }, + data: { ouraAccessTokenEncrypted: "not-a-real-token" }, + }); + const { recordSyncSuccess } = await import("@/lib/integrations/status"); + await recordSyncSuccess(profile.id, "oura"); + + const session = await prisma.session.create({ + data: { + userId: guardian.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + await switchSessionTo(session.id, profile.id); + + const data = await readIntegrations(); + expect(data.recordId).toBe(profile.id); + expect(data.integrations).toHaveLength(8); + + const byKey = new Map(data.integrations.map((i) => [i.integration, i])); + const oura = byKey.get("oura"); + expect(oura?.health).not.toBeNull(); + expect(oura?.health?.state).toBe("ok"); + // Health and the legacy state agree on "a pipe exists". + expect(oura?.state).toBe("connected"); + + // The guardian's Polar is the guardian's, not the record's. + expect(byKey.get("polar")?.health).toBeNull(); + for (const key of [ + "withings", + "whoop", + "fitbit", + "nightscout", + "google-health", + "strava", + ]) { + expect(byKey.get(key)?.health, key).toBeNull(); + } + }); +}); From 1a5b09a73c6085465f1b0be42fb288bee7e10523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:06:03 +0200 Subject: [PATCH 20/40] feat(settings): one integration card for every connection Every connection in Settings renders the same card from the server's health field: a badge with one word, one sentence per reason, the newest data as a meta line, a collapsed details block whose text is exactly what the copy button copies, and an action row with the rare actions in a menu and at most one primary button. The per-provider banners, the raw provider error line, the token expiry, the failure counter and the button rows are gone; every capability they carried is reachable from the sentence, the one button, the menu or the details. The managed-profile view renders the same card, read-only. A structural guard keeps the cards off the retired fields. --- e2e/v137-sharing-managed-profiles.spec.ts | 15 +- messages/de.json | 93 ++- messages/en.json | 93 ++- messages/es.json | 93 ++- messages/fr.json | 93 ++- messages/it.json | 93 ++- messages/ko.json | 93 ++- messages/pl.json | 93 ++- .../daily-reads-refetch-guard.test.ts | 6 +- .../destructive-control-guard.test.ts | 32 +- ...on-failure-response-consumer-guard.test.ts | 4 +- .../success-affordance-guard.test.ts | 46 +- src/__tests__/v1341-locale-parity.test.ts | 4 +- ...en-outcome-response-consumer-guard.test.ts | 16 +- .../__tests__/integrations-section.test.tsx | 348 ++++----- .../__tests__/apple-health-card.test.tsx | 132 ++-- .../connections-panel-coverage.test.tsx | 30 +- .../__tests__/nightscout-card.test.tsx | 223 +++--- .../__tests__/oauth-provider-card.test.tsx | 274 -------- .../__tests__/pill-mapper.test.tsx | 138 ---- .../__tests__/plan-and-scope-notes.test.tsx | 65 -- .../__tests__/whoop-card.test.tsx | 51 -- .../integrations/apple-health-card.tsx | 184 +++-- .../integrations/connections-panel.tsx | 104 ++- .../settings/integrations/fitbit-card.tsx | 537 -------------- .../integrations/google-health-card.tsx | 599 +++------------- .../integrations/integration-fallback-row.tsx | 69 +- .../metric-freshness-disclosure.tsx | 184 ----- .../settings/integrations/nightscout-card.tsx | 661 ++++++++---------- .../integrations/oauth-provider-card.tsx | 551 --------------- .../settings/integrations/oura-card.tsx | 35 - .../settings/integrations/oura-scope-note.tsx | 60 -- .../settings/integrations/polar-card.tsx | 35 - .../settings/integrations/shared.tsx | 195 +----- .../settings/integrations/strava-card.tsx | 36 - .../settings/integrations/whoop-card.tsx | 519 -------------- .../settings/integrations/withings-card.tsx | 573 --------------- .../integrations/withings-plan-note.tsx | 84 --- .../settings/managed-integration-status.tsx | 126 ++-- 39 files changed, 1505 insertions(+), 5082 deletions(-) delete mode 100644 src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx delete mode 100644 src/components/settings/integrations/__tests__/pill-mapper.test.tsx delete mode 100644 src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx delete mode 100644 src/components/settings/integrations/__tests__/whoop-card.test.tsx delete mode 100644 src/components/settings/integrations/fitbit-card.tsx delete mode 100644 src/components/settings/integrations/metric-freshness-disclosure.tsx delete mode 100644 src/components/settings/integrations/oauth-provider-card.tsx delete mode 100644 src/components/settings/integrations/oura-card.tsx delete mode 100644 src/components/settings/integrations/oura-scope-note.tsx delete mode 100644 src/components/settings/integrations/polar-card.tsx delete mode 100644 src/components/settings/integrations/strava-card.tsx delete mode 100644 src/components/settings/integrations/whoop-card.tsx delete mode 100644 src/components/settings/integrations/withings-card.tsx delete mode 100644 src/components/settings/integrations/withings-plan-note.tsx diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index f4e225f74..7cd1f9f0b 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -628,8 +628,21 @@ test.describe.serial("scoped sharing browser journeys", () => { ); await expect(integrations).toBeVisible(); await expect(integrations).toHaveAttribute("data-record-id", accountId); + // Read-only: the shared integration cards render, but nothing on them + // acts on a provider. The only buttons are each card's own "details for a + // bug report" toggle and its copy button. await expect( - integrations.locator("button, input, select, textarea"), + integrations.locator('[data-slot="integration-card"]'), + ).toHaveCount(8); + await expect( + integrations.locator( + '[data-slot="integration-primary-action"], [data-slot="integration-menu-trigger"], input, select, textarea', + ), + ).toHaveCount(0); + await expect( + integrations.locator( + 'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])', + ), ).toHaveCount(0); await page.goBack(); diff --git a/messages/de.json b/messages/de.json index e7a8a1dd0..ceee0d7f6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5315,7 +5315,8 @@ "unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar", "unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.", "integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz.", - "integrationStatusDetail": "Verbindungssteuerungen sind nicht verfügbar." + "integrationStatusDetail": "Ein betreutes Profil zeigt seine verbundenen Dienste nur als Status. Verbinden, neu verbinden, synchronisieren und trennen ist für Betreuende nicht möglich.", + "integrationStatusDocs": "So funktioniert das Teilen" }, "sections": { "account": { @@ -6391,7 +6392,6 @@ "whoopDisconnectDescription": "Die Verbindung zu WHOOP wird getrennt. Bereits synchronisierte Daten bleiben erhalten.", "whoopConnect": "Mit WHOOP verbinden", "whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.", - "whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…", "whoopOauthConnected": "WHOOP verbunden. Die erste Synchronisierung startet im Hintergrund.", "whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen", "whoopOauthError": { @@ -6427,7 +6427,6 @@ "fitbitDisconnectDescription": "Die Verbindung zu Fitbit wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.", "fitbitConnect": "Mit Fitbit verbinden", "fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Fitbit zu verbinden.", - "fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…", "fitbitOauthConnected": "Fitbit verbunden. Die erste Synchronisierung startet im Hintergrund.", "fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen", "googleHealth": "Google Health", @@ -6472,9 +6471,6 @@ "googleHealthDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Bereits synchronisierte Daten bleiben erhalten.", "googleHealthConnect": "Mit Google verbinden", "googleHealthNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.", - "googleHealthBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…", - "googleHealthReauthBanner": "Google hat die Verbindung ablaufen lassen. Das passiert regelmäßig, solange deine OAuth-App im Testmodus bleibt. Verbinde neu, um die Synchronisierung fortzusetzen.", - "googleHealthReconnect": "Neu verbinden", "googleHealthOauthConnected": "Google Health verbunden. Die erste Synchronisierung startet im Hintergrund.", "googleHealthOauthFailed": "Verbindung zu Google Health fehlgeschlagen", "googleHealthOauthError": { @@ -6499,16 +6495,7 @@ }, "integrations": { "withings": { - "reconnect": { - "banner": { - "title": "Aktivitäts-Sync verfügbar", - "body": "Deine Withings-Verbindung wurde autorisiert, bevor der Aktivitäts-Sync hinzugefügt wurde. Verbinde einmal neu, um Schritte, aktive Energie, Geh- und Laufdistanz sowie Stockwerke aus deinen Withings-Geräten zu übernehmen.", - "action": "Withings neu verbinden" - } - }, "plan": { - "title": "Einige Werte brauchen den kostenpflichtigen API-Plan von Withings", - "body": "Withings bietet {list} jetzt nur noch im kostenpflichtigen API-Plan an, und deine Withings-Entwickler-App nutzt den kostenlosen Plan. Gewicht, Blutdruck, Körperzusammensetzung, Herzfrequenz und Schlaf werden weiter synchronisiert.", "link": "Mehr zu den API-Plänen von Withings", "items": { "spo2": "SpO2", @@ -6538,10 +6525,7 @@ }, "oura": { "scope": { - "body": "Verbinde Oura einmal neu, um {list} freizugeben. Alles andere wird weiter synchronisiert.", - "action": "Oura neu verbinden", "items": { - "spo2": "SpO2", "vo2max": "VO2max", "cardio_age": "kardiovaskuläres Alter", "resilience": "Resilienz" @@ -6560,7 +6544,6 @@ "minutesAgo": "vor {count} min", "hoursAgo": "vor {count} Std.", "daysAgo": "vor {count} d", - "resumeCta": "Wieder verbinden", "resumeSuccess": "Integration wieder aktiv", "resumeError": "Wieder verbinden fehlgeschlagen", "stale": "Verbunden, keine neuen Daten", @@ -6574,9 +6557,7 @@ "workouts": "Workouts" }, "integrationFallback": { - "description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte.", - "lastAttempt": "Letzter Versuch {relative}", - "noAttempt": "Kein Sync-Versuch aufgezeichnet" + "description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte." }, "withingsViewData": "Messwerte ansehen", "whoopViewData": "Erholung ansehen", @@ -7124,7 +7105,6 @@ "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.", "progress": { "title": "Sync-Fortschritt", "receivedLabel": "Empfangene Datensätze", @@ -7217,6 +7197,73 @@ "displayCard": { "title": "Darstellung", "description": "Sprache, Einheiten und Formate, gespeichert, sobald du sie änderst." + }, + "integrationCard": { + "state": { + "ok": "Läuft", + "limited": "Eingeschränkt", + "degraded": "Verzögert", + "actionRequired": "Aktion nötig" + }, + "reason": { + "dataArriving": "Daten kommen an.", + "dataArrivingHistory": "Daten kommen an. Der ältere Verlauf wird im Hintergrund geladen.", + "firstSyncPending": "Verbunden. Die erste Synchronisierung läuft in Kürze.", + "planLimited": "Daten kommen an. Dein {provider}-Tarif enthält {list} nicht.", + "permissionLimited": "Daten kommen an. Für {list} fehlt die Freigabe.", + "providerErrors": "{provider} antwortet seit {since} nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.", + "providerErrorsNoSince": "{provider} antwortet gerade nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.", + "syncStalled": "Seit {since} hat HealthLog nicht mehr synchronisiert. Das liegt am Server, nicht an deinem Gerät.", + "syncStalledNoSince": "HealthLog synchronisiert gerade nicht. Das liegt am Server, nicht an deinem Gerät.", + "noNewData": "Seit {since} sind keine neuen Daten gekommen.", + "noNewDataNoSince": "Seit einer Weile sind keine neuen Daten gekommen.", + "noNewDataApple": "Seit {since} sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.", + "noNewDataAppleNoSince": "Seit einer Weile sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.", + "unexpectedSilence": "Seit {since} kommen keine Daten, obwohl {provider} sonst täglich liefert.", + "unexpectedSilenceNoSince": "Es kommen keine Daten, obwohl {provider} sonst täglich liefert.", + "reauthRequired": "Die Verbindung zu {provider} ist abgelaufen. Verbinde neu, damit wieder Daten kommen.", + "pausedAfterErrors": "{provider} ist pausiert, weil es seit {since} nur Fehler gab.", + "pausedAfterErrorsNoSince": "{provider} ist pausiert, weil es zuletzt nur Fehler gab." + }, + "when": { + "today": "heute, {time}", + "yesterday": "gestern, {time}", + "date": "{date}, {time}" + }, + "items": { + "activity": "Aktivität", + "sleep": "Schlaf" + }, + "moreValuesOne": "{count} weiteren Wert", + "moreValuesFew": "{count} weitere Werte", + "moreValuesOther": "{count} weitere Werte", + "notConnected": "Nicht verbunden.", + "lastData": "Letzte Daten: {when}", + "lastDataNone": "Letzte Daten: noch keine", + "reconnect": "Neu verbinden", + "resume": "Fortsetzen", + "menuLabel": "Weitere Aktionen für {provider}", + "menu": { + "credentials": "Zugangsdaten ändern", + "address": "Adresse und Token ändern" + }, + "syncing": "Synchronisierung läuft…", + "details": { + "title": "Details für eine Fehlermeldung", + "none": "keine", + "state": "Zustand", + "since": "seit", + "lastSync": "Letzte Synchronisierung", + "lastData": "Letzte Daten", + "failingParts": "fehlende Teile", + "recentCalls": "Letzte Aufrufe", + "unavailable": "Nicht verfügbar", + "unavailableList": "Nicht verfügbar: {list}", + "causePlan": "Tarif", + "causePermission": "Freigabe", + "failureStreak": "Fehlerserie: {count} von {threshold}", + "copyFailed": "Kopieren hat nicht geklappt. Markiere den Text und kopiere ihn von Hand." + } } }, "admin": { diff --git a/messages/en.json b/messages/en.json index 11a517a80..c491a8092 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5315,7 +5315,8 @@ "unavailableTitle": "Unavailable in this shared record", "unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.", "integrationStatusDescription": "Connection status for this managed record.", - "integrationStatusDetail": "Connection controls are unavailable." + "integrationStatusDetail": "A managed profile shows its connected services as a status only. Connecting, reconnecting, syncing and disconnecting are not available to guardians.", + "integrationStatusDocs": "How sharing works" }, "sections": { "account": { @@ -6391,7 +6392,6 @@ "whoopDisconnectDescription": "The connection to WHOOP will be disconnected. Previously synced data will be preserved.", "whoopConnect": "Connect with WHOOP", "whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.", - "whoopBackfillInProgress": "Importing your WHOOP history in the background…", "whoopOauthConnected": "WHOOP connected. The first sync starts in the background.", "whoopOauthFailed": "WHOOP connection failed", "whoopOauthError": { @@ -6427,7 +6427,6 @@ "fitbitDisconnectDescription": "The connection to Fitbit will be disconnected. Previously synced data will be preserved.", "fitbitConnect": "Connect with Fitbit", "fitbitNoCredentials": "Please enter your API credentials above to connect Fitbit.", - "fitbitBackfillInProgress": "Importing your Fitbit history in the background…", "fitbitOauthConnected": "Fitbit connected. The first sync starts in the background.", "fitbitOauthFailed": "Fitbit connection failed", "googleHealth": "Google Health", @@ -6472,9 +6471,6 @@ "googleHealthDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.", "googleHealthConnect": "Connect with Google", "googleHealthNoCredentials": "Please enter your API credentials above to connect Google Health.", - "googleHealthBackfillInProgress": "Importing your Google Health history in the background…", - "googleHealthReauthBanner": "Google has expired the connection. This happens periodically while your OAuth app stays in Testing mode. Reconnect to resume syncing.", - "googleHealthReconnect": "Reconnect", "googleHealthOauthConnected": "Google Health connected. The first sync starts in the background.", "googleHealthOauthFailed": "Google Health connection failed", "googleHealthOauthError": { @@ -6499,16 +6495,7 @@ }, "integrations": { "withings": { - "reconnect": { - "banner": { - "title": "Activity sync available", - "body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.", - "action": "Reconnect Withings" - } - }, "plan": { - "title": "Some readings need Withings' paid API plan", - "body": "Withings now offers {list} only in its paid API plan, and your Withings developer app is on the free plan. Weight, blood pressure, body composition, heart rate and sleep keep syncing.", "link": "About the Withings API plans", "items": { "spo2": "SpO2", @@ -6538,10 +6525,7 @@ }, "oura": { "scope": { - "body": "Reconnect Oura once to allow {list}. Everything else keeps syncing.", - "action": "Reconnect Oura", "items": { - "spo2": "SpO2", "vo2max": "VO2 max", "cardio_age": "cardiovascular age", "resilience": "resilience" @@ -6560,7 +6544,6 @@ "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", - "resumeCta": "Reconnect", "resumeSuccess": "Integration resumed", "resumeError": "Reconnect failed", "stale": "Connected, no recent data", @@ -6574,9 +6557,7 @@ "workouts": "Workouts" }, "integrationFallback": { - "description": "Configured on this account without a settings card of its own.", - "lastAttempt": "Last attempt {relative}", - "noAttempt": "No sync attempt recorded" + "description": "Configured on this account without a settings card of its own." }, "withingsViewData": "View your measurements", "whoopViewData": "View your recovery", @@ -7124,7 +7105,6 @@ "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.", "progress": { "title": "Sync progress", "receivedLabel": "Records received", @@ -7217,6 +7197,73 @@ "displayCard": { "title": "Display", "description": "Language, units and formats, saved as soon as you change them." + }, + "integrationCard": { + "state": { + "ok": "Working", + "limited": "Limited", + "degraded": "Delayed", + "actionRequired": "Action needed" + }, + "reason": { + "dataArriving": "Data is arriving.", + "dataArrivingHistory": "Data is arriving. Older history is loading in the background.", + "firstSyncPending": "Connected. The first sync runs shortly.", + "planLimited": "Data is arriving. Your {provider} plan does not include {list}.", + "permissionLimited": "Data is arriving. Permission is missing for {list}.", + "providerErrors": "{provider} has not answered reliably since {since}. HealthLog keeps trying; nothing to do on your side.", + "providerErrorsNoSince": "{provider} is not answering reliably right now. HealthLog keeps trying; nothing to do on your side.", + "syncStalled": "HealthLog has not synced since {since}. That is on the server, not your device.", + "syncStalledNoSince": "HealthLog is not syncing right now. That is on the server, not your device.", + "noNewData": "No new data since {since}.", + "noNewDataNoSince": "No new data for a while.", + "noNewDataApple": "No new data since {since}. Open HealthLog on your iPhone once.", + "noNewDataAppleNoSince": "No new data for a while. Open HealthLog on your iPhone once.", + "unexpectedSilence": "Nothing has arrived since {since}, although {provider} usually delivers daily.", + "unexpectedSilenceNoSince": "Nothing is arriving, although {provider} usually delivers daily.", + "reauthRequired": "The {provider} connection has expired. Reconnect to keep data coming.", + "pausedAfterErrors": "{provider} is paused after failing since {since}.", + "pausedAfterErrorsNoSince": "{provider} is paused after repeated failures." + }, + "when": { + "today": "today, {time}", + "yesterday": "yesterday, {time}", + "date": "{date}, {time}" + }, + "items": { + "activity": "activity", + "sleep": "sleep" + }, + "moreValuesOne": "{count} more", + "moreValuesFew": "{count} more", + "moreValuesOther": "{count} more", + "notConnected": "Not connected.", + "lastData": "Last data: {when}", + "lastDataNone": "Last data: none yet", + "reconnect": "Reconnect", + "resume": "Resume", + "menuLabel": "More actions for {provider}", + "menu": { + "credentials": "Change credentials", + "address": "Change address and token" + }, + "syncing": "Syncing…", + "details": { + "title": "Details for a bug report", + "none": "none", + "state": "State", + "since": "since", + "lastSync": "Last sync", + "lastData": "Last data", + "failingParts": "failing parts", + "recentCalls": "Recent calls", + "unavailable": "Not available", + "unavailableList": "Not available: {list}", + "causePlan": "plan", + "causePermission": "permission", + "failureStreak": "Failure streak: {count} of {threshold}", + "copyFailed": "Could not copy. Select the text and copy it by hand." + } } }, "admin": { diff --git a/messages/es.json b/messages/es.json index 43c808b32..a3a5c2164 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5315,7 +5315,8 @@ "unavailableTitle": "No disponible en este registro compartido", "unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.", "integrationStatusDescription": "Estado de conexión de este registro gestionado.", - "integrationStatusDetail": "Los controles de conexión no están disponibles." + "integrationStatusDetail": "Un perfil gestionado muestra sus servicios conectados solo como estado. Conectar, volver a conectar, sincronizar y desconectar no está disponible para quienes lo gestionan.", + "integrationStatusDocs": "Cómo funciona compartir" }, "sections": { "account": { @@ -6391,7 +6392,6 @@ "whoopDisconnectDescription": "Se desconectará la conexión con WHOOP. Los datos ya sincronizados se conservarán.", "whoopConnect": "Conectar con WHOOP", "whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.", - "whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…", "whoopOauthConnected": "WHOOP conectado: la primera sincronización empieza en segundo plano.", "whoopOauthFailed": "Error al conectar con WHOOP", "whoopOauthError": { @@ -6427,7 +6427,6 @@ "fitbitDisconnectDescription": "Se desconectará la conexión con Fitbit. Los datos sincronizados previamente se conservarán.", "fitbitConnect": "Conectar con Fitbit", "fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Fitbit.", - "fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…", "fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.", "fitbitOauthFailed": "Error al conectar con Fitbit", "googleHealth": "Google Health", @@ -6472,9 +6471,6 @@ "googleHealthDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos ya sincronizados se conservarán.", "googleHealthConnect": "Conectar con Google", "googleHealthNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.", - "googleHealthBackfillInProgress": "Importando tu historial de Google Health en segundo plano…", - "googleHealthReauthBanner": "Google ha caducado la conexión: ocurre de forma periódica mientras tu app de OAuth permanece en modo de prueba. Vuelve a conectar para reanudar la sincronización.", - "googleHealthReconnect": "Volver a conectar", "googleHealthOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.", "googleHealthOauthFailed": "Error al conectar con Google Health", "googleHealthOauthError": { @@ -6499,16 +6495,7 @@ }, "integrations": { "withings": { - "reconnect": { - "banner": { - "title": "Sincronización de actividad disponible", - "body": "Tu conexión con Withings se autorizó antes de añadir la sincronización de actividad. Vuelve a conectar una vez para habilitar la importación de pasos, energía activa, distancia caminando y corriendo, y pisos subidos desde tus dispositivos Withings.", - "action": "Reconectar Withings" - } - }, "plan": { - "title": "Algunos valores requieren el plan de API de pago de Withings", - "body": "Withings ahora ofrece {list} solo en su plan de API de pago, y tu app de desarrollador de Withings usa el plan gratuito. El peso, la presión arterial, la composición corporal, la frecuencia cardíaca y el sueño se siguen sincronizando.", "link": "Sobre los planes de API de Withings", "items": { "spo2": "SpO2", @@ -6538,10 +6525,7 @@ }, "oura": { "scope": { - "body": "Vuelve a conectar Oura una vez para permitir {list}. Todo lo demás se sigue sincronizando.", - "action": "Volver a conectar Oura", "items": { - "spo2": "SpO2", "vo2max": "VO2 máx.", "cardio_age": "edad cardiovascular", "resilience": "resiliencia" @@ -6560,7 +6544,6 @@ "minutesAgo": "hace {count} min", "hoursAgo": "hace {count} h", "daysAgo": "hace {count} d", - "resumeCta": "Reconectar", "resumeSuccess": "Integración reanudada", "resumeError": "Reconexión fallida", "stale": "Conectado, sin datos recientes", @@ -6574,9 +6557,7 @@ "workouts": "Entrenamientos" }, "integrationFallback": { - "description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia.", - "lastAttempt": "Último intento {relative}", - "noAttempt": "Ningún intento de sincronización registrado" + "description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia." }, "withingsViewData": "Ver tus mediciones", "whoopViewData": "Ver tu recuperación", @@ -7124,7 +7105,6 @@ "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.", "progress": { "title": "Progreso de sincronización", "receivedLabel": "Registros recibidos", @@ -7217,6 +7197,73 @@ "displayCard": { "title": "Visualización", "description": "Idioma, unidades y formatos; se guardan en cuanto los cambias." + }, + "integrationCard": { + "state": { + "ok": "Funciona", + "limited": "Limitada", + "degraded": "Con retraso", + "actionRequired": "Requiere acción" + }, + "reason": { + "dataArriving": "Están llegando datos.", + "dataArrivingHistory": "Están llegando datos. El historial más antiguo se carga en segundo plano.", + "firstSyncPending": "Conectado. La primera sincronización se hará en breve.", + "planLimited": "Están llegando datos. Tu plan de {provider} no incluye {list}.", + "permissionLimited": "Están llegando datos. Falta el permiso para {list}.", + "providerErrors": "{provider} no responde de forma fiable desde {since}. HealthLog sigue intentándolo; no tienes que hacer nada.", + "providerErrorsNoSince": "{provider} no responde de forma fiable en este momento. HealthLog sigue intentándolo; no tienes que hacer nada.", + "syncStalled": "HealthLog no sincroniza desde {since}. Es cosa del servidor, no de tu dispositivo.", + "syncStalledNoSince": "HealthLog no está sincronizando en este momento. Es cosa del servidor, no de tu dispositivo.", + "noNewData": "No han llegado datos nuevos desde {since}.", + "noNewDataNoSince": "Hace tiempo que no llegan datos nuevos.", + "noNewDataApple": "No han llegado datos nuevos desde {since}. Abre HealthLog una vez en el iPhone.", + "noNewDataAppleNoSince": "Hace tiempo que no llegan datos nuevos. Abre HealthLog una vez en el iPhone.", + "unexpectedSilence": "No llega nada desde {since}, aunque {provider} suele enviar datos a diario.", + "unexpectedSilenceNoSince": "No llega nada, aunque {provider} suele enviar datos a diario.", + "reauthRequired": "La conexión con {provider} ha caducado. Vuelve a conectar para seguir recibiendo datos.", + "pausedAfterErrors": "{provider} está en pausa porque desde {since} solo ha habido errores.", + "pausedAfterErrorsNoSince": "{provider} está en pausa porque últimamente solo ha habido errores." + }, + "when": { + "today": "hoy, {time}", + "yesterday": "ayer, {time}", + "date": "{date}, {time}" + }, + "items": { + "activity": "actividad", + "sleep": "sueño" + }, + "moreValuesOne": "{count} valor más", + "moreValuesFew": "{count} valores más", + "moreValuesOther": "{count} valores más", + "notConnected": "No conectado.", + "lastData": "Últimos datos: {when}", + "lastDataNone": "Últimos datos: todavía ninguno", + "reconnect": "Volver a conectar", + "resume": "Reanudar", + "menuLabel": "Más acciones para {provider}", + "menu": { + "credentials": "Cambiar credenciales", + "address": "Cambiar dirección y token" + }, + "syncing": "Sincronizando…", + "details": { + "title": "Detalles para un informe de error", + "none": "ninguno", + "state": "Estado", + "since": "desde", + "lastSync": "Última sincronización", + "lastData": "Últimos datos", + "failingParts": "partes con fallos", + "recentCalls": "Últimas llamadas", + "unavailable": "No disponible", + "unavailableList": "No disponible: {list}", + "causePlan": "plan", + "causePermission": "permiso", + "failureStreak": "Racha de fallos: {count} de {threshold}", + "copyFailed": "No se pudo copiar. Selecciona el texto y cópialo a mano." + } } }, "admin": { diff --git a/messages/fr.json b/messages/fr.json index 3f3de9ecd..6ffd7c77e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5315,7 +5315,8 @@ "unavailableTitle": "Indisponible dans ce dossier partagé", "unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.", "integrationStatusDescription": "État de connexion pour ce dossier géré.", - "integrationStatusDetail": "Les commandes de connexion ne sont pas disponibles." + "integrationStatusDetail": "Un profil géré affiche ses services connectés uniquement sous forme d’état. Les personnes qui le gèrent ne peuvent ni connecter, ni reconnecter, ni synchroniser, ni déconnecter.", + "integrationStatusDocs": "Comment fonctionne le partage" }, "sections": { "account": { @@ -6391,7 +6392,6 @@ "whoopDisconnectDescription": "La connexion à WHOOP sera déconnectée. Les données déjà synchronisées seront conservées.", "whoopConnect": "Se connecter avec WHOOP", "whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.", - "whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…", "whoopOauthConnected": "WHOOP connecté. La première synchronisation démarre en arrière-plan.", "whoopOauthFailed": "Échec de la connexion à WHOOP", "whoopOauthError": { @@ -6427,7 +6427,6 @@ "fitbitDisconnectDescription": "La connexion à Fitbit sera déconnectée. Les données déjà synchronisées seront conservées.", "fitbitConnect": "Se connecter à Fitbit", "fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Fitbit.", - "fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…", "fitbitOauthConnected": "Fitbit connecté. La première synchronisation démarre en arrière-plan.", "fitbitOauthFailed": "Échec de la connexion à Fitbit", "googleHealth": "Google Health", @@ -6472,9 +6471,6 @@ "googleHealthDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.", "googleHealthConnect": "Se connecter avec Google", "googleHealthNoCredentials": "Saisis tes identifiants d'API ci-dessus pour connecter Google Health.", - "googleHealthBackfillInProgress": "Importation de ton historique Google Health en arrière-plan…", - "googleHealthReauthBanner": "Google a expiré la connexion. Cela arrive régulièrement tant que ton application OAuth reste en mode Test. Reconnecte-toi pour reprendre la synchronisation.", - "googleHealthReconnect": "Se reconnecter", "googleHealthOauthConnected": "Google Health connecté. La première synchronisation démarre en arrière-plan.", "googleHealthOauthFailed": "Échec de la connexion à Google Health", "googleHealthOauthError": { @@ -6499,16 +6495,7 @@ }, "integrations": { "withings": { - "reconnect": { - "banner": { - "title": "Synchronisation d’activité disponible", - "body": "Votre connexion Withings a été autorisée avant l’ajout de la synchronisation d’activité. Reconnectez-vous une fois pour activer l’import des pas, de l’énergie active, de la distance à pied et en course, et des étages gravis depuis vos appareils Withings.", - "action": "Reconnecter Withings" - } - }, "plan": { - "title": "Certaines mesures nécessitent l’offre API payante de Withings", - "body": "Withings ne propose désormais {list} que dans son offre API payante, et votre application développeur Withings utilise l’offre gratuite. Le poids, la tension artérielle, la composition corporelle, la fréquence cardiaque et le sommeil continuent d’être synchronisés.", "link": "En savoir plus sur les offres API de Withings", "items": { "spo2": "la SpO2", @@ -6538,10 +6525,7 @@ }, "oura": { "scope": { - "body": "Reconnectez Oura une fois pour autoriser {list}. Tout le reste continue d’être synchronisé.", - "action": "Reconnecter Oura", "items": { - "spo2": "la SpO2", "vo2max": "la VO2 max", "cardio_age": "l’âge cardiovasculaire", "resilience": "la résilience" @@ -6560,7 +6544,6 @@ "minutesAgo": "il y a {count} min", "hoursAgo": "il y a {count} h", "daysAgo": "il y a {count} j", - "resumeCta": "Reconnecter", "resumeSuccess": "Intégration reprise", "resumeError": "Échec de la reconnexion", "stale": "Connecté, aucune donnée récente", @@ -6574,9 +6557,7 @@ "workouts": "Séances" }, "integrationFallback": { - "description": "Configuré sur ce compte sans carte de réglages dédiée.", - "lastAttempt": "Dernière tentative {relative}", - "noAttempt": "Aucune tentative de synchronisation enregistrée" + "description": "Configuré sur ce compte sans carte de réglages dédiée." }, "withingsViewData": "Voir vos mesures", "whoopViewData": "Voir votre récupération", @@ -7124,7 +7105,6 @@ "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.", "progress": { "title": "Progression de la synchronisation", "receivedLabel": "Enregistrements reçus", @@ -7217,6 +7197,73 @@ "displayCard": { "title": "Affichage", "description": "Langue, unités et formats, enregistrés dès que vous les modifiez." + }, + "integrationCard": { + "state": { + "ok": "Fonctionne", + "limited": "Limitée", + "degraded": "Retardée", + "actionRequired": "Action requise" + }, + "reason": { + "dataArriving": "Les données arrivent.", + "dataArrivingHistory": "Les données arrivent. L’historique plus ancien se charge en arrière-plan.", + "firstSyncPending": "Connecté. La première synchronisation aura lieu sous peu.", + "planLimited": "Les données arrivent. Votre forfait {provider} n’inclut pas {list}.", + "permissionLimited": "Les données arrivent. L’autorisation manque pour {list}.", + "providerErrors": "{provider} ne répond plus de façon fiable depuis {since}. HealthLog continue d’essayer, vous n’avez rien à faire.", + "providerErrorsNoSince": "{provider} ne répond pas de façon fiable en ce moment. HealthLog continue d’essayer, vous n’avez rien à faire.", + "syncStalled": "HealthLog n’a plus synchronisé depuis {since}. Le problème vient du serveur, pas de votre appareil.", + "syncStalledNoSince": "HealthLog ne synchronise pas en ce moment. Le problème vient du serveur, pas de votre appareil.", + "noNewData": "Aucune nouvelle donnée depuis {since}.", + "noNewDataNoSince": "Aucune nouvelle donnée depuis un moment.", + "noNewDataApple": "Aucune nouvelle donnée depuis {since}. Ouvrez HealthLog une fois sur votre iPhone.", + "noNewDataAppleNoSince": "Aucune nouvelle donnée depuis un moment. Ouvrez HealthLog une fois sur votre iPhone.", + "unexpectedSilence": "Rien n’est arrivé depuis {since}, alors que {provider} envoie d’habitude des données chaque jour.", + "unexpectedSilenceNoSince": "Rien n’arrive, alors que {provider} envoie d’habitude des données chaque jour.", + "reauthRequired": "La connexion à {provider} a expiré. Reconnectez-vous pour continuer à recevoir des données.", + "pausedAfterErrors": "{provider} est en pause, car il n’y a eu que des erreurs depuis {since}.", + "pausedAfterErrorsNoSince": "{provider} est en pause, car il n’y a eu que des erreurs récemment." + }, + "when": { + "today": "aujourd’hui, {time}", + "yesterday": "hier, {time}", + "date": "{date}, {time}" + }, + "items": { + "activity": "l’activité", + "sleep": "le sommeil" + }, + "moreValuesOne": "{count} autre valeur", + "moreValuesFew": "{count} autres valeurs", + "moreValuesOther": "{count} autres valeurs", + "notConnected": "Non connecté.", + "lastData": "Dernières données : {when}", + "lastDataNone": "Dernières données : aucune pour l’instant", + "reconnect": "Reconnecter", + "resume": "Reprendre", + "menuLabel": "Plus d’actions pour {provider}", + "menu": { + "credentials": "Modifier les identifiants", + "address": "Modifier l’adresse et le jeton" + }, + "syncing": "Synchronisation en cours…", + "details": { + "title": "Détails pour un rapport de bug", + "none": "aucune", + "state": "État", + "since": "depuis", + "lastSync": "Dernière synchronisation", + "lastData": "Dernières données", + "failingParts": "parties en échec", + "recentCalls": "Derniers appels", + "unavailable": "Non disponible", + "unavailableList": "Non disponible : {list}", + "causePlan": "forfait", + "causePermission": "autorisation", + "failureStreak": "Série d’échecs : {count} sur {threshold}", + "copyFailed": "La copie a échoué. Sélectionnez le texte et copiez-le à la main." + } } }, "admin": { diff --git a/messages/it.json b/messages/it.json index 4853184ed..278694f6d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5315,7 +5315,8 @@ "unavailableTitle": "Non disponibile in questo record condiviso", "unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.", "integrationStatusDescription": "Stato di connessione per questo record gestito.", - "integrationStatusDetail": "I controlli di connessione non sono disponibili." + "integrationStatusDetail": "Un profilo gestito mostra i servizi collegati solo come stato. Chi lo gestisce non può collegare, ricollegare, sincronizzare o scollegare.", + "integrationStatusDocs": "Come funziona la condivisione" }, "sections": { "account": { @@ -6391,7 +6392,6 @@ "whoopDisconnectDescription": "La connessione a WHOOP verrà disconnessa. I dati già sincronizzati saranno conservati.", "whoopConnect": "Connetti con WHOOP", "whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.", - "whoopBackfillInProgress": "Importazione dello storico WHOOP in background…", "whoopOauthConnected": "WHOOP collegato: la prima sincronizzazione parte in background.", "whoopOauthFailed": "Connessione a WHOOP non riuscita", "whoopOauthError": { @@ -6427,7 +6427,6 @@ "fitbitDisconnectDescription": "La connessione a Fitbit verrà disconnessa. I dati già sincronizzati saranno conservati.", "fitbitConnect": "Connetti con Fitbit", "fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Fitbit.", - "fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…", "fitbitOauthConnected": "Fitbit collegato: la prima sincronizzazione parte in background.", "fitbitOauthFailed": "Connessione a Fitbit non riuscita", "googleHealth": "Google Health", @@ -6472,9 +6471,6 @@ "googleHealthDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.", "googleHealthConnect": "Connetti con Google", "googleHealthNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.", - "googleHealthBackfillInProgress": "Importazione della cronologia di Google Health in background…", - "googleHealthReauthBanner": "Google ha fatto scadere la connessione. Accade periodicamente finché la tua app OAuth resta in modalità Test. Riconnettiti per riprendere la sincronizzazione.", - "googleHealthReconnect": "Riconnetti", "googleHealthOauthConnected": "Google Health connesso: la prima sincronizzazione parte in background.", "googleHealthOauthFailed": "Connessione a Google Health non riuscita", "googleHealthOauthError": { @@ -6499,16 +6495,7 @@ }, "integrations": { "withings": { - "reconnect": { - "banner": { - "title": "Sincronizzazione attività disponibile", - "body": "La tua connessione Withings è stata autorizzata prima dell’aggiunta della sincronizzazione attività. Riconnettiti una volta per abilitare l’importazione di passi, energia attiva, distanza a piedi e di corsa, e piani saliti dai tuoi dispositivi Withings.", - "action": "Riconnetti Withings" - } - }, "plan": { - "title": "Alcuni valori richiedono il piano API a pagamento di Withings", - "body": "Withings ora offre {list} solo nel suo piano API a pagamento, e la tua app sviluppatore Withings usa il piano gratuito. Peso, pressione arteriosa, composizione corporea, frequenza cardiaca e sonno continuano a sincronizzarsi.", "link": "Informazioni sui piani API di Withings", "items": { "spo2": "SpO2", @@ -6538,10 +6525,7 @@ }, "oura": { "scope": { - "body": "Riconnetti Oura una volta per consentire {list}. Tutto il resto continua a sincronizzarsi.", - "action": "Riconnetti Oura", "items": { - "spo2": "SpO2", "vo2max": "VO2 max", "cardio_age": "età cardiovascolare", "resilience": "resilienza" @@ -6560,7 +6544,6 @@ "minutesAgo": "{count} min fa", "hoursAgo": "{count} h fa", "daysAgo": "{count} g fa", - "resumeCta": "Riconnetti", "resumeSuccess": "Integrazione ripresa", "resumeError": "Riconnessione fallita", "stale": "Connesso, nessun dato recente", @@ -6574,9 +6557,7 @@ "workouts": "Allenamenti" }, "integrationFallback": { - "description": "Configurato su questo account ma senza una scheda di impostazioni propria.", - "lastAttempt": "Ultimo tentativo {relative}", - "noAttempt": "Nessun tentativo di sincronizzazione registrato" + "description": "Configurato su questo account ma senza una scheda di impostazioni propria." }, "withingsViewData": "Vedi le tue misurazioni", "whoopViewData": "Vedi il tuo recupero", @@ -7124,7 +7105,6 @@ "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.", "progress": { "title": "Avanzamento della sincronizzazione", "receivedLabel": "Record ricevuti", @@ -7217,6 +7197,73 @@ "displayCard": { "title": "Visualizzazione", "description": "Lingua, unità e formati, salvati appena li modifichi." + }, + "integrationCard": { + "state": { + "ok": "Funziona", + "limited": "Limitata", + "degraded": "In ritardo", + "actionRequired": "Azione richiesta" + }, + "reason": { + "dataArriving": "I dati stanno arrivando.", + "dataArrivingHistory": "I dati stanno arrivando. Lo storico meno recente si carica in background.", + "firstSyncPending": "Connesso. La prima sincronizzazione partirà a breve.", + "planLimited": "I dati stanno arrivando. Il tuo piano {provider} non include {list}.", + "permissionLimited": "I dati stanno arrivando. Manca l’autorizzazione per {list}.", + "providerErrors": "{provider} non risponde in modo affidabile da {since}. HealthLog continua a riprovare, non devi fare nulla.", + "providerErrorsNoSince": "{provider} al momento non risponde in modo affidabile. HealthLog continua a riprovare, non devi fare nulla.", + "syncStalled": "HealthLog non sincronizza più da {since}. Dipende dal server, non dal tuo dispositivo.", + "syncStalledNoSince": "HealthLog al momento non sincronizza. Dipende dal server, non dal tuo dispositivo.", + "noNewData": "Nessun dato nuovo da {since}.", + "noNewDataNoSince": "Nessun dato nuovo da un po’ di tempo.", + "noNewDataApple": "Nessun dato nuovo da {since}. Apri HealthLog una volta sull’iPhone.", + "noNewDataAppleNoSince": "Nessun dato nuovo da un po’ di tempo. Apri HealthLog una volta sull’iPhone.", + "unexpectedSilence": "Non arriva nulla da {since}, anche se di solito {provider} invia dati ogni giorno.", + "unexpectedSilenceNoSince": "Non arriva nulla, anche se di solito {provider} invia dati ogni giorno.", + "reauthRequired": "La connessione a {provider} è scaduta. Riconnetti per continuare a ricevere dati.", + "pausedAfterErrors": "{provider} è in pausa perché da {since} ci sono stati solo errori.", + "pausedAfterErrorsNoSince": "{provider} è in pausa perché ultimamente ci sono stati solo errori." + }, + "when": { + "today": "oggi, {time}", + "yesterday": "ieri, {time}", + "date": "{date}, {time}" + }, + "items": { + "activity": "attività", + "sleep": "sonno" + }, + "moreValuesOne": "{count} altro valore", + "moreValuesFew": "altri {count} valori", + "moreValuesOther": "altri {count} valori", + "notConnected": "Non connesso.", + "lastData": "Ultimi dati: {when}", + "lastDataNone": "Ultimi dati: ancora nessuno", + "reconnect": "Riconnetti", + "resume": "Riprendi", + "menuLabel": "Altre azioni per {provider}", + "menu": { + "credentials": "Modifica credenziali", + "address": "Modifica indirizzo e token" + }, + "syncing": "Sincronizzazione in corso…", + "details": { + "title": "Dettagli per una segnalazione di errore", + "none": "nessuno", + "state": "Stato", + "since": "da", + "lastSync": "Ultima sincronizzazione", + "lastData": "Ultimi dati", + "failingParts": "parti con errori", + "recentCalls": "Ultime chiamate", + "unavailable": "Non disponibile", + "unavailableList": "Non disponibile: {list}", + "causePlan": "piano", + "causePermission": "autorizzazione", + "failureStreak": "Serie di errori: {count} su {threshold}", + "copyFailed": "Copia non riuscita. Seleziona il testo e copialo a mano." + } } }, "admin": { diff --git a/messages/ko.json b/messages/ko.json index 122c25d92..1bab20bec 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5315,7 +5315,8 @@ "unavailableTitle": "이 공유 기록에서는 사용할 수 없어요", "unavailableDescription": "다른 사람의 기록을 보는 동안에는 이 설정 영역을 사용할 수 없어요.", "integrationStatusDescription": "이 관리 기록의 연결 상태예요.", - "integrationStatusDetail": "연결 설정은 사용할 수 없어요." + "integrationStatusDetail": "관리 프로필은 연결된 서비스를 상태로만 보여 줘요. 보호자는 연결, 다시 연결, 동기화, 연결 해제를 할 수 없어요.", + "integrationStatusDocs": "공유 방식 알아보기" }, "sections": { "account": { @@ -6391,7 +6392,6 @@ "whoopDisconnectDescription": "WHOOP 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.", "whoopConnect": "WHOOP으로 연결", "whoopNoCredentials": "WHOOP을 연결하려면 위에 API 자격 증명을 입력해 주세요.", - "whoopBackfillInProgress": "WHOOP 기록을 백그라운드에서 가져오는 중이에요…", "whoopOauthConnected": "WHOOP을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "whoopOauthFailed": "WHOOP 연결에 실패했어요", "whoopOauthError": { @@ -6427,7 +6427,6 @@ "fitbitDisconnectDescription": "Fitbit 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.", "fitbitConnect": "Fitbit으로 연결", "fitbitNoCredentials": "Fitbit을 연결하려면 위에 API 자격 증명을 입력해 주세요.", - "fitbitBackfillInProgress": "Fitbit 기록을 백그라운드에서 가져오는 중이에요…", "fitbitOauthConnected": "Fitbit을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "fitbitOauthFailed": "Fitbit 연결에 실패했어요", "googleHealth": "Google Health", @@ -6472,9 +6471,6 @@ "googleHealthDisconnectDescription": "Google Health 연결이 해제돼요. 이미 동기화한 데이터는 그대로 남아요.", "googleHealthConnect": "Google로 연결", "googleHealthNoCredentials": "Google Health를 연결하려면 위에 API 자격 증명을 입력해 주세요.", - "googleHealthBackfillInProgress": "Google Health 기록을 백그라운드에서 가져오는 중이에요…", - "googleHealthReauthBanner": "Google이 연결을 만료시켰어요. OAuth 앱이 테스트 모드로 있는 동안 주기적으로 생기는 일이에요. 다시 연결하면 동기화가 이어져요.", - "googleHealthReconnect": "다시 연결", "googleHealthOauthConnected": "Google Health를 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "googleHealthOauthFailed": "Google Health 연결에 실패했어요", "googleHealthOauthError": { @@ -6499,16 +6495,7 @@ }, "integrations": { "withings": { - "reconnect": { - "banner": { - "title": "활동 동기화를 쓸 수 있어요", - "body": "활동 동기화가 추가되기 전에 Withings 연결을 승인했어요. 한 번만 다시 연결하면 Withings 기기에서 걸음 수, 활동 에너지, 걷기·달리기 거리, 오른 층수를 가져올 수 있어요.", - "action": "Withings 다시 연결" - } - }, "plan": { - "title": "일부 측정값은 Withings 유료 API 요금제가 필요해요", - "body": "Withings는 이제 {list} 데이터를 유료 API 요금제에서만 제공하고, 사용 중인 Withings 개발자 앱은 무료 요금제예요. 체중, 혈압, 체성분, 심박수, 수면은 계속 동기화돼요.", "link": "Withings API 요금제 알아보기", "items": { "spo2": "SpO2", @@ -6538,10 +6525,7 @@ }, "oura": { "scope": { - "body": "{list} 데이터를 허용하려면 Oura를 한 번 다시 연결하세요. 나머지는 계속 동기화돼요.", - "action": "Oura 다시 연결", "items": { - "spo2": "SpO2", "vo2max": "VO2 max", "cardio_age": "심혈관 나이", "resilience": "회복탄력성" @@ -6560,7 +6544,6 @@ "minutesAgo": "{count}분 전", "hoursAgo": "{count}시간 전", "daysAgo": "{count}일 전", - "resumeCta": "다시 연결", "resumeSuccess": "연동을 다시 시작했어요", "resumeError": "다시 연결하지 못했어요", "stale": "연결됨, 최근 데이터 없음", @@ -6574,9 +6557,7 @@ "workouts": "운동" }, "integrationFallback": { - "description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요.", - "lastAttempt": "마지막 시도 {relative}", - "noAttempt": "동기화 시도 기록이 없어요" + "description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요." }, "withingsViewData": "측정값 보기", "whoopViewData": "회복도 보기", @@ -7124,7 +7105,6 @@ "backgroundStep": "백그라운드 앱 새로고침을 켠 채로 두세요. 앱은 화면에 떠 있을 때와 iOS가 백그라운드 시간을 줄 때 새 데이터를 자동으로 동기화해요.", "importNote": "예전 기록이 필요하거나 iOS 앱을 쓸 수 없나요? Apple Health XML/ZIP 업로드는 한 번만 쓰는 대안이라 실시간 동기화는 켜지지 않아요.", "importAction": "일회성 불러오기 열기", - "staleNote": "일주일 넘게 Apple Health 데이터가 들어오지 않았어요. iPhone에서 HealthLog 앱을 열고 Apple Health 권한을 확인해 주세요.", "progress": { "title": "동기화 진행 상황", "receivedLabel": "받은 기록 수", @@ -7217,6 +7197,73 @@ "displayCard": { "title": "표시", "description": "언어, 단위, 형식은 바꾸는 즉시 저장돼요." + }, + "integrationCard": { + "state": { + "ok": "작동 중", + "limited": "제한됨", + "degraded": "지연됨", + "actionRequired": "조치 필요" + }, + "reason": { + "dataArriving": "데이터가 들어오고 있어요.", + "dataArrivingHistory": "데이터가 들어오고 있어요. 이전 기록은 백그라운드에서 불러오는 중이에요.", + "firstSyncPending": "연결됐어요. 첫 동기화가 곧 시작돼요.", + "planLimited": "데이터가 들어오고 있어요. {provider} 요금제에는 {list} 항목이 포함되지 않아요.", + "permissionLimited": "데이터가 들어오고 있어요. {list} 항목에 대한 권한이 없어요.", + "providerErrors": "{since}부터 {provider}의 응답이 불안정해요. HealthLog가 계속 다시 시도하니 따로 하실 일은 없어요.", + "providerErrorsNoSince": "지금 {provider}의 응답이 불안정해요. HealthLog가 계속 다시 시도하니 따로 하실 일은 없어요.", + "syncStalled": "{since}부터 HealthLog가 동기화하지 못했어요. 기기가 아니라 서버 쪽 문제예요.", + "syncStalledNoSince": "지금 HealthLog가 동기화하지 못하고 있어요. 기기가 아니라 서버 쪽 문제예요.", + "noNewData": "{since}부터 새 데이터가 들어오지 않았어요.", + "noNewDataNoSince": "한동안 새 데이터가 들어오지 않았어요.", + "noNewDataApple": "{since}부터 새 데이터가 들어오지 않았어요. iPhone에서 HealthLog를 한 번 열어 주세요.", + "noNewDataAppleNoSince": "한동안 새 데이터가 들어오지 않았어요. iPhone에서 HealthLog를 한 번 열어 주세요.", + "unexpectedSilence": "{provider}는 보통 매일 데이터를 보내는데 {since}부터 아무것도 들어오지 않았어요.", + "unexpectedSilenceNoSince": "{provider}는 보통 매일 데이터를 보내는데 지금은 아무것도 들어오지 않아요.", + "reauthRequired": "{provider} 연결이 만료됐어요. 데이터를 계속 받으려면 다시 연결해 주세요.", + "pausedAfterErrors": "{since}부터 오류만 발생해서 {provider}를 일시 중지했어요.", + "pausedAfterErrorsNoSince": "최근 오류만 발생해서 {provider}를 일시 중지했어요." + }, + "when": { + "today": "오늘 {time}", + "yesterday": "어제 {time}", + "date": "{date} {time}" + }, + "items": { + "activity": "활동", + "sleep": "수면" + }, + "moreValuesOne": "그 외 {count}개", + "moreValuesFew": "그 외 {count}개", + "moreValuesOther": "그 외 {count}개", + "notConnected": "연결되지 않음.", + "lastData": "마지막 데이터: {when}", + "lastDataNone": "마지막 데이터: 아직 없음", + "reconnect": "다시 연결", + "resume": "재개", + "menuLabel": "{provider} 추가 작업", + "menu": { + "credentials": "인증 정보 변경", + "address": "주소와 토큰 변경" + }, + "syncing": "동기화 중…", + "details": { + "title": "오류 신고용 세부 정보", + "none": "없음", + "state": "상태", + "since": "시작", + "lastSync": "마지막 동기화", + "lastData": "마지막 데이터", + "failingParts": "실패한 부분", + "recentCalls": "최근 호출", + "unavailable": "사용 불가", + "unavailableList": "사용 불가: {list}", + "causePlan": "요금제", + "causePermission": "권한", + "failureStreak": "연속 실패: {threshold}회 중 {count}회", + "copyFailed": "복사하지 못했어요. 텍스트를 선택해서 직접 복사해 주세요." + } } }, "admin": { diff --git a/messages/pl.json b/messages/pl.json index 5907e3c21..85ce5c3c3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5315,7 +5315,8 @@ "unavailableTitle": "Niedostępne w tym udostępnionym rekordzie", "unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.", "integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu.", - "integrationStatusDetail": "Elementy sterujące połączeniem są niedostępne." + "integrationStatusDetail": "Profil zarządzany pokazuje połączone usługi tylko jako stan. Opiekunowie nie mogą łączyć, ponownie łączyć, synchronizować ani rozłączać.", + "integrationStatusDocs": "Jak działa udostępnianie" }, "sections": { "account": { @@ -6391,7 +6392,6 @@ "whoopDisconnectDescription": "Połączenie z WHOOP zostanie zakończone. Wcześniej zsynchronizowane dane zostaną zachowane.", "whoopConnect": "Połącz z WHOOP", "whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.", - "whoopBackfillInProgress": "Importowanie historii WHOOP w tle…", "whoopOauthConnected": "WHOOP połączony. Pierwsza synchronizacja startuje w tle.", "whoopOauthFailed": "Połączenie z WHOOP nie powiodło się", "whoopOauthError": { @@ -6427,7 +6427,6 @@ "fitbitDisconnectDescription": "Połączenie z Fitbit zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.", "fitbitConnect": "Połącz z Fitbit", "fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Fitbit.", - "fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…", "fitbitOauthConnected": "Fitbit połączony. Pierwsza synchronizacja startuje w tle.", "fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się", "googleHealth": "Google Health", @@ -6472,9 +6471,6 @@ "googleHealthDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.", "googleHealthConnect": "Połącz z Google", "googleHealthNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć Google Health.", - "googleHealthBackfillInProgress": "Importowanie historii Google Health w tle…", - "googleHealthReauthBanner": "Google unieważnił połączenie. Dzieje się to okresowo, dopóki Twoja aplikacja OAuth pozostaje w trybie testowym. Połącz ponownie, aby wznowić synchronizację.", - "googleHealthReconnect": "Połącz ponownie", "googleHealthOauthConnected": "Google Health połączony. Pierwsza synchronizacja startuje w tle.", "googleHealthOauthFailed": "Połączenie z Google Health nie powiodło się", "googleHealthOauthError": { @@ -6499,16 +6495,7 @@ }, "integrations": { "withings": { - "reconnect": { - "banner": { - "title": "Dostępna synchronizacja aktywności", - "body": "Twoje połączenie z Withings zostało autoryzowane przed dodaniem synchronizacji aktywności. Połącz ponownie jeden raz, aby włączyć import kroków, energii aktywnej, dystansu marszu i biegu oraz pokonanych pięter z urządzeń Withings.", - "action": "Połącz ponownie Withings" - } - }, "plan": { - "title": "Niektóre pomiary wymagają płatnego planu API Withings", - "body": "Withings udostępnia teraz te dane tylko w płatnym planie API: {list}. Twoja aplikacja deweloperska Withings korzysta z bezpłatnego planu. Waga, ciśnienie krwi, skład ciała, tętno i sen są nadal synchronizowane.", "link": "Więcej o planach API Withings", "items": { "spo2": "SpO2", @@ -6538,10 +6525,7 @@ }, "oura": { "scope": { - "body": "Połącz ponownie Oura jeden raz, aby zezwolić na te dane: {list}. Wszystko inne jest nadal synchronizowane.", - "action": "Połącz ponownie Oura", "items": { - "spo2": "SpO2", "vo2max": "VO2 max", "cardio_age": "wiek sercowo-naczyniowy", "resilience": "odporność" @@ -6560,7 +6544,6 @@ "minutesAgo": "{count} min temu", "hoursAgo": "{count} godz. temu", "daysAgo": "{count} dni temu", - "resumeCta": "Połącz ponownie", "resumeSuccess": "Integracja wznowiona", "resumeError": "Ponowne połączenie nieudane", "stale": "Połączono, brak nowych danych", @@ -6574,9 +6557,7 @@ "workouts": "Treningi" }, "integrationFallback": { - "description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień.", - "lastAttempt": "Ostatnia próba {relative}", - "noAttempt": "Brak zapisanej próby synchronizacji" + "description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień." }, "withingsViewData": "Zobacz swoje pomiary", "whoopViewData": "Zobacz swoją regenerację", @@ -7124,7 +7105,6 @@ "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.", "progress": { "title": "Postęp synchronizacji", "receivedLabel": "Odebrane rekordy", @@ -7217,6 +7197,73 @@ "displayCard": { "title": "Wyświetlanie", "description": "Język, jednostki i formaty zapisywane od razu po zmianie." + }, + "integrationCard": { + "state": { + "ok": "Działa", + "limited": "Ograniczone", + "degraded": "Opóźnione", + "actionRequired": "Wymaga działania" + }, + "reason": { + "dataArriving": "Dane napływają.", + "dataArrivingHistory": "Dane napływają. Starsza historia wczytuje się w tle.", + "firstSyncPending": "Połączono. Pierwsza synchronizacja ruszy za chwilę.", + "planLimited": "Dane napływają. Twój plan {provider} nie obejmuje tych danych: {list}.", + "permissionLimited": "Dane napływają. Brakuje uprawnień do tych danych: {list}.", + "providerErrors": "{provider} od {since} nie odpowiada niezawodnie. HealthLog próbuje dalej, nie musisz nic robić.", + "providerErrorsNoSince": "{provider} nie odpowiada teraz niezawodnie. HealthLog próbuje dalej, nie musisz nic robić.", + "syncStalled": "HealthLog nie synchronizuje od {since}. To problem po stronie serwera, nie twojego urządzenia.", + "syncStalledNoSince": "HealthLog teraz nie synchronizuje. To problem po stronie serwera, nie twojego urządzenia.", + "noNewData": "Od {since} nie napłynęły nowe dane.", + "noNewDataNoSince": "Od dłuższego czasu nie napłynęły nowe dane.", + "noNewDataApple": "Od {since} nie napłynęły nowe dane. Otwórz raz HealthLog na iPhonie.", + "noNewDataAppleNoSince": "Od dłuższego czasu nie napłynęły nowe dane. Otwórz raz HealthLog na iPhonie.", + "unexpectedSilence": "Od {since} nic nie napływa, chociaż {provider} zwykle dostarcza dane codziennie.", + "unexpectedSilenceNoSince": "Nic nie napływa, chociaż {provider} zwykle dostarcza dane codziennie.", + "reauthRequired": "Połączenie z {provider} wygasło. Połącz ponownie, aby dane znów napływały.", + "pausedAfterErrors": "{provider} jest wstrzymany, bo od {since} występowały same błędy.", + "pausedAfterErrorsNoSince": "{provider} jest wstrzymany, bo ostatnio występowały same błędy." + }, + "when": { + "today": "dziś, {time}", + "yesterday": "wczoraj, {time}", + "date": "{date}, {time}" + }, + "items": { + "activity": "aktywność", + "sleep": "sen" + }, + "moreValuesOne": "{count} inna wartość", + "moreValuesFew": "{count} inne wartości", + "moreValuesOther": "{count} innych wartości", + "notConnected": "Nie połączono.", + "lastData": "Ostatnie dane: {when}", + "lastDataNone": "Ostatnie dane: jeszcze brak", + "reconnect": "Połącz ponownie", + "resume": "Wznów", + "menuLabel": "Więcej działań dla {provider}", + "menu": { + "credentials": "Zmień dane dostępowe", + "address": "Zmień adres i token" + }, + "syncing": "Trwa synchronizacja…", + "details": { + "title": "Szczegóły do zgłoszenia błędu", + "none": "brak", + "state": "Stan", + "since": "od", + "lastSync": "Ostatnia synchronizacja", + "lastData": "Ostatnie dane", + "failingParts": "części z błędami", + "recentCalls": "Ostatnie wywołania", + "unavailable": "Niedostępne", + "unavailableList": "Niedostępne: {list}", + "causePlan": "plan", + "causePermission": "uprawnienie", + "failureStreak": "Seria błędów: {count} z {threshold}", + "copyFailed": "Nie udało się skopiować. Zaznacz tekst i skopiuj go ręcznie." + } } }, "admin": { diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index 3dda6b7c3..201759d15 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -126,12 +126,10 @@ const NO_REFETCH_ALLOWLIST = [ // Background integration-sync completion on the Settings surface — the user // is configuring an integration, not acting on the hero; convergence via the // daily reads' own poll/focus refetch is the intended behaviour. - "components/settings/integrations/fitbit-card.tsx", "components/settings/integrations/google-health-card.tsx", + "components/settings/integrations/integration-actions.tsx", "components/settings/integrations/nightscout-card.tsx", - "components/settings/integrations/oauth-provider-card.tsx", - "components/settings/integrations/whoop-card.tsx", - "components/settings/integrations/withings-card.tsx", + "components/settings/integrations/oauth-integration-card.tsx", // Cycle writes: `cycleDependentKeys` carries no daily-read key and neither // the dashboard snapshot nor the Today digest reads cycle rows today, so // the forced refetch would be a no-op round-trip. The bundle is in the diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 46c0d9655..a6284a586 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -616,21 +616,25 @@ const REGISTRY: DestructiveEntry[] = [ }, // ── Integrations ──────────────────────────────────────────────────────── - ...( - [ - ["withings-card.tsx", "Withings"], - ["fitbit-card.tsx", "Fitbit"], - ["whoop-card.tsx", "WHOOP"], - ["google-health-card.tsx", "Google Health"], - ["nightscout-card.tsx", "Nightscout"], - ["oauth-provider-card.tsx", "the generic OAuth providers"], - ] as const - ).map(([f, name]): DestructiveEntry => ({ - file: `components/settings/integrations/${f}`, - destroys: `the stored ${name} credentials — reconnecting means authorising again`, + // Every connection renders the shared integration card, whose three-dot + // menu raises the confirmation for the destructive entry; the controllers + // below only hold the request. + { + file: "components/settings/integrations/oauth-integration-card.tsx", + destroys: + "the stored Withings, WHOOP, Fitbit, Google Health, Polar, Oura or Strava grant — reconnecting means authorising again", + triggers: ["components/settings/integrations/integration-card.tsx"], recovery: "permanent", - confirm: ["AlertDialog"], - })), + confirm: ["ConfirmDialog"], + }, + { + file: "components/settings/integrations/nightscout-card.tsx", + destroys: + "the stored Nightscout address and token — reconnecting means typing them again", + triggers: ["components/settings/integrations/integration-card.tsx"], + recovery: "permanent", + confirm: ["ConfirmDialog"], + }, { file: "components/settings/integrations/document-sources-card.tsx", destroys: diff --git a/src/__tests__/integration-failure-response-consumer-guard.test.ts b/src/__tests__/integration-failure-response-consumer-guard.test.ts index 9d5438e17..cb86c7212 100644 --- a/src/__tests__/integration-failure-response-consumer-guard.test.ts +++ b/src/__tests__/integration-failure-response-consumer-guard.test.ts @@ -441,7 +441,7 @@ function assertBucketConsumerLineage( const declaration = file.statements.find( (statement): statement is ts.FunctionDeclaration => ts.isFunctionDeclaration(statement) && - statement.name?.text === "pillFailurePropsFor", + statement.name?.text === "failureStreakFor", ); expect(declaration?.body).toBeDefined(); let bucketInitializer: string | null = null; @@ -473,7 +473,7 @@ function assertBucketConsumerLineage( for (const kind of kinds) { expect( accesses, - `pillFailurePropsFor must read buckets.${kind} from that response value`, + `failureStreakFor must read buckets.${kind} from that response value`, ).toContain(`buckets.${kind}`); } } diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index c6023429a..86828e5e6 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -369,20 +369,26 @@ const PINNED_AFFORDANCES: Record< "src/components/settings/integrations/connections-panel.tsx": { "toast.success": 2, }, - "src/components/settings/integrations/fitbit-card.tsx": { "text-success": 2 }, - "src/components/settings/integrations/google-health-card.tsx": { + // The saved-credentials confirmation line. + "src/components/settings/integrations/credentials-form.tsx": { + "text-success": 1, + }, + // "Test connection" from the menu: the same ok line the button shows. + "src/components/settings/integrations/integration-actions.tsx": { + CheckCircle2: 2, + "text-success": 1, + }, + // The "Working" badge tone, and "Copied" beside the diagnostics copy button. + "src/components/settings/integrations/integration-card.tsx": { "text-success": 2, }, "src/components/settings/integrations/nightscout-card.tsx": { "text-success": 1, }, - "src/components/settings/integrations/oauth-provider-card.tsx": { + // A paused connection resumed. + "src/components/settings/integrations/oauth-integration-card.tsx": { "text-success": 1, }, - "src/components/settings/integrations/whoop-card.tsx": { "text-success": 2 }, - "src/components/settings/integrations/withings-card.tsx": { - "text-success": 2, - }, "src/components/settings/mcp-section.tsx": { "text-success": 4, "toast.success": 2, @@ -466,9 +472,11 @@ const RESULT_SURFACES: ReadonlyArray<{ through: /IntakeImportSkipGroups/, forbidsMarkers: true, }, - // The seven surfaces that were still gated on the transport. + // The provider sync surfaces that were still gated on the transport. Every + // integration card reports a sync through the shared action lines; Google + // Health keeps its own because a run can outlive its request. { - path: "src/components/settings/integrations/fitbit-card.tsx", + path: "src/components/settings/integrations/integration-actions.tsx", through: /WrittenOutcomeLine/, forbidsMarkers: false, }, @@ -477,26 +485,6 @@ const RESULT_SURFACES: ReadonlyArray<{ through: /WrittenOutcomeLine/, forbidsMarkers: false, }, - { - path: "src/components/settings/integrations/nightscout-card.tsx", - through: /WrittenOutcomeLine/, - forbidsMarkers: false, - }, - { - path: "src/components/settings/integrations/whoop-card.tsx", - through: /WrittenOutcomeLine/, - forbidsMarkers: false, - }, - { - path: "src/components/settings/integrations/withings-card.tsx", - through: /WrittenOutcomeLine/, - forbidsMarkers: false, - }, - { - path: "src/components/settings/integrations/oauth-provider-card.tsx", - through: /WrittenOutcomeLine/, - forbidsMarkers: false, - }, { path: "src/components/labs/ocr-review-dialog.tsx", through: /toastWrittenOutcome/, diff --git a/src/__tests__/v1341-locale-parity.test.ts b/src/__tests__/v1341-locale-parity.test.ts index bdd0570da..f6f0b5ec8 100644 --- a/src/__tests__/v1341-locale-parity.test.ts +++ b/src/__tests__/v1341-locale-parity.test.ts @@ -30,7 +30,9 @@ const REQUIRED_KEYS = [ "common.networkError", "settings.googleHealthSyncResult", "settings.googleHealthSyncFailed", - "settings.googleHealthBackfillInProgress", + // `settings.googleHealthBackfillInProgress` left this list in v1.42.4: the + // history-loading line became a sentence variant of the shared integration + // card (`settings.integrationCard.reason.dataArrivingHistory`). "settings.syncOutcome.empty", "settings.syncOutcome.partial", "settings.syncOutcome.failed", diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index bc9948329..8f96081bb 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -644,7 +644,7 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [ id: "fitbit", path: join(ROOT, "src/app/api/fitbit/sync/route.ts"), responseClass: "resolved-with-full-sync-echo", - card: join(SYNC_CARD_ROOT, "fitbit-card.tsx"), + card: join(SYNC_CARD_ROOT, "integration-actions.tsx"), exceptions: { fullSync: FULL_SYNC_EXCEPTION }, }, { @@ -663,42 +663,42 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [ id: "nightscout", path: join(ROOT, "src/app/api/nightscout/sync/route.ts"), responseClass: "resolved", - card: join(SYNC_CARD_ROOT, "nightscout-card.tsx"), + card: join(SYNC_CARD_ROOT, "integration-actions.tsx"), exceptions: {}, }, { id: "oura", path: join(ROOT, "src/app/api/oura/sync/route.ts"), responseClass: "resolved", - card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"), + card: join(SYNC_CARD_ROOT, "integration-actions.tsx"), exceptions: {}, }, { id: "polar", path: join(ROOT, "src/app/api/polar/sync/route.ts"), responseClass: "resolved", - card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"), + card: join(SYNC_CARD_ROOT, "integration-actions.tsx"), exceptions: {}, }, { id: "strava", path: join(ROOT, "src/app/api/strava/sync/route.ts"), responseClass: "resolved", - card: join(SYNC_CARD_ROOT, "oauth-provider-card.tsx"), + card: join(SYNC_CARD_ROOT, "integration-actions.tsx"), exceptions: {}, }, { id: "whoop", path: join(ROOT, "src/app/api/whoop/sync/route.ts"), responseClass: "resolved-with-full-sync-echo", - card: join(SYNC_CARD_ROOT, "whoop-card.tsx"), + card: join(SYNC_CARD_ROOT, "integration-actions.tsx"), exceptions: { fullSync: FULL_SYNC_EXCEPTION }, }, { id: "withings", path: join(ROOT, "src/app/api/withings/sync/route.ts"), responseClass: "resolved-with-full-sync-echo", - card: join(SYNC_CARD_ROOT, "withings-card.tsx"), + card: join(SYNC_CARD_ROOT, "integration-actions.tsx"), exceptions: { fullSync: FULL_SYNC_EXCEPTION }, }, ] as const; @@ -908,7 +908,7 @@ function assertSyncCardConsumesFetchedResponse( return; } const endpoint = responseInitializer.arguments[0]; - const expectedEndpoint = path.endsWith("oauth-provider-card.tsx") + const expectedEndpoint = path.endsWith("integration-actions.tsx") ? "`/api/${provider}/sync`" : `"/api/${routeId}/sync"`; expect( diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index dfc482093..1979ab0d6 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -1,28 +1,17 @@ /** - * v1.4.19 Phase A5 — Settings → Integrations status consolidation. + * Settings → Integrations: one status display per card. * - * The maintainer's verbatim concern (paraphrased): - * - Withings card had 3 status displays — top-right "connected" - * badge, mid-card "connected / last successful / last attempt" - * trio container, and the badge inside that container. - * - Mood Log card had 4 — same as Withings PLUS a "letzter Sync" - * line at the very bottom. + * v1.4.19 consolidated three and four status surfaces per card into one pill. + * v1.42.4 replaces the pill with the shared card's badge, rendered from the + * server's `health`, and removes what had grown back around it: the raw + * provider error line, the failure counter, the per-provider banners. * - * After A5: each card has EXACTLY ONE status display (the pill, top - * right). Error context (the failure message itself, when actionable) - * is allowed to surface below the pill since it carries information - * the pill can't fit. Timestamp repetition is gone. - * - * The tests pin: - * 1. Healthy Withings + Mood Log: ONE pill per card, no banner. - * 2. Withings transient error: ONE pill per card + error message, - * NO redundant "{n}/{threshold} consecutive failures" badge, - * NO "Last successful sync / Last attempt" trio. - * 3. Withings reauth: pill carries the "Error — reconnect" label. - * 4. Mood Log disconnected: pill says "Not connected", no other - * status display surfaces. - * 5. no card paints a divider under its header - * — consistency the maintainer explicitly called out. + * The tests pin, on the whole section: + * 1. one badge per connected card, and nothing else that states a status; + * 2. the stored provider error text never reaches the page; + * 3. a paused connection offers exactly one Resume; + * 4. no card paints a divider under its header; + * 5. every not-connected card links its setup guide. */ import { describe, expect, it, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -104,227 +93,162 @@ function count(html: string, needle: string): number { return n; } -describe("IntegrationsSection — single-status-display contract (A5)", () => { +const PROVIDERS = [ + "withings", + "whoop", + "fitbit", + "google-health", + "polar", + "oura", + "strava", + "nightscout", +] as const; + +function health(overrides: Record = {}) { + return { + state: "ok", + reason: "data_arriving", + since: null, + action: null, + unavailable: [], + lastSuccessAt: "2026-05-09T18:00:00.000Z", + lastDataAt: "2026-05-09T17:00:00.000Z", + diagnostics: { + ledgerState: "connected", + failingLegs: [], + failingSince: null, + lastAttemptAt: "2026-05-09T18:00:00.000Z", + verdict: "fresh", + recentCalls: [], + }, + ...overrides, + }; +} + +function disconnected(integration: string) { + return { + integration, + lastError: null, + connected: false, + configured: false, + available: false, + health: null, + }; +} + +/** The Withings card's markup, up to the next card. */ +function withingsCard(html: string): string { + const start = html.indexOf('data-testid="withings-card"'); + const next = html.indexOf('data-slot="integration-card"', start + 1); + return html.slice(start, next === -1 ? undefined : next); +} + +/** Withings as given, every other provider present and not connected. */ +function envelopeWith(withings: Record) { + return { + threshold: 3, + integrations: [ + { + integration: "withings", + lastError: null, + connected: true, + configured: true, + ...withings, + }, + ...PROVIDERS.filter((p) => p !== "withings").map(disconnected), + ], + }; +} + +describe("IntegrationsSection — one status display per card", () => { beforeEach(() => { integrationStatusPayload = null; }); - it("renders exactly ONE status pill per card when both integrations are healthy", () => { - setIntegrationStatus({ - threshold: 3, - integrations: [ - { - integration: "withings", - state: "connected", - lastSuccessAt: "2026-05-09T18:00:00.000Z", - lastAttemptAt: "2026-05-09T18:00:00.000Z", - lastError: null, - connected: true, - configured: true, - legacyLastSyncedAt: "2026-05-09T18:00:00.000Z", - hasActivityScope: true, - syncHealth: { verdict: "fresh", since: null }, - }, - ], - }); + it("renders exactly one badge for the connected card and no other status surface", () => { + setIntegrationStatus(envelopeWith({ health: health() })); const html = render(); - // Exactly one pill per card → 8 (Withings, WHOOP, Fitbit, Google Health, - // Polar, Oura, Strava, Nightscout). The Garmin info note carries no pill; - // the Apple Health card's pill keeps its own testid. - expect(count(html, 'data-testid="integration-status-pill"')).toBe(8); - // The redundant banner from v1.4.15 is gone. + expect(count(html, 'data-slot="integration-health-badge"')).toBe(1); + expect(html).not.toContain('data-testid="integration-status-pill"'); expect(html).not.toContain('data-testid="integration-status-banner"'); - // Card-body "letzter Sync" repetition is gone — no - // "Last sync:" label outside the pill. - expect(html).not.toContain("Last sync:"); - // No "Last successful sync" / "Last attempt" trio. expect(html).not.toContain("Last successful sync"); expect(html).not.toContain("Last attempt"); + // The seven other providers and Apple Health are not connected and say so + // once each. + expect(count(html, ">Not connected.<")).toBe(8); }); - it("Withings transient error keeps the pill + error text but drops the {n}/{threshold} duplicate badge", () => { - setIntegrationStatus({ - threshold: 3, - integrations: [ - { - integration: "withings", - state: "error_transient", - lastSuccessAt: "2026-05-09T08:00:00.000Z", - lastAttemptAt: "2026-05-09T18:00:00.000Z", - lastError: "Withings refresh error: 503 - upstream", - connected: true, - configured: true, - legacyLastSyncedAt: "2026-05-09T08:00:00.000Z", - hasActivityScope: true, - syncHealth: { verdict: "failing", since: "2026-05-09T08:00:00.000Z" }, + it("never shows the stored provider error or a failure counter", () => { + setIntegrationStatus( + envelopeWith({ + lastError: "Withings refresh error: 503 - upstream", + consecutiveFailuresByKind: { + transient: 2, + reauth_required: 0, + persistent: 0, }, - ], - }); + health: health({ + state: "degraded", + reason: "provider_errors", + since: "2026-05-09T08:00:00.000Z", + }), + }), + ); const html = render(); - // A 503 is not the user's to fix, so the pill carries the warning tone — - // red is reserved for "reconnect actually helps". - expect(html).toContain('data-state="warning"'); - expect(html).not.toContain('data-state="error"'); - // Actionable error message still surfaces (we kept it because - // the pill can't fit "Withings refresh error: 503 - upstream"). - expect(html).toContain("Withings refresh error: 503 - upstream"); - // …but the consecutive-failure badge that doubled the pill's - // signal is gone. + expect(html).toMatch( + /data-slot="integration-health-badge"[^>]*data-state="degraded"/, + ); + expect(html).not.toContain("Withings refresh error"); expect(html).not.toContain("2/3 consecutive failures"); - expect(html).not.toContain("Last successful sync"); - expect(html).not.toContain("Last attempt"); + // Not the user's to fix: the Withings card offers no button. + expect(withingsCard(html)).toContain('data-testid="withings-menu"'); + expect(withingsCard(html)).not.toContain( + 'data-slot="integration-primary-action"', + ); }); - it("Withings reauth state surfaces 'Error — reconnect' on the pill (the maintainer's wording)", () => { - setIntegrationStatus({ - threshold: 3, - integrations: [ - { - integration: "withings", - state: "error_reauth", - lastSuccessAt: "2026-05-08T12:00:00.000Z", - lastAttemptAt: "2026-05-09T18:00:00.000Z", - lastError: "Withings refresh error: 100 - invalid_grant", - connected: true, - configured: true, - legacyLastSyncedAt: "2026-05-08T12:00:00.000Z", - tokenExpired: true, - hasActivityScope: true, - syncHealth: { - verdict: "reauth_required", - since: "2026-05-09T18:00:00.000Z", - }, - }, - ], - }); + it("a paused connection offers exactly one Resume", () => { + setIntegrationStatus( + envelopeWith({ + lastError: "Withings activity error: 293", + health: health({ + state: "action_required", + reason: "paused_after_errors", + since: "2026-05-09T18:00:00.000Z", + action: "resume", + }), + }), + ); const html = render(); - expect(html).toContain('data-state="error"'); - // Old "Reconnect required" banner is gone — the pill is now - // the canonical surface. - expect(html).not.toContain('data-testid="integration-status-banner"'); - }); - - it("disconnected state shows 'Not connected' on the pill, no extra status surfaces", () => { - setIntegrationStatus({ - threshold: 3, - integrations: [ - { - integration: "withings", - state: "disconnected", - lastSuccessAt: "2026-05-08T10:00:00.000Z", - lastAttemptAt: "2026-05-08T10:00:00.000Z", - lastError: null, - syncHealth: { verdict: "disconnected", since: null }, - }, - ], - }); - - const html = render(); - expect(html).toContain("Not connected"); - expect(html).not.toContain('data-testid="integration-status-banner"'); - // No trailing "letzter Sync" line outside the pill. - expect(html).not.toContain("Last sync:"); - }); - - // v1.4.43 W14 — parked-state surface check. - // The pill flips to `data-state="parked"` and the resume banner - // renders with the reconnect CTA. The legacy `error` reconnect text - // (red) must NOT appear because the user-facing copy is the lighter - // "Paused: reconnect" string, not "Error — reconnect". - it("Withings parked state surfaces the parked pill + resume CTA", () => { - setIntegrationStatus({ - threshold: 3, - integrations: [ - { - integration: "withings", - state: "parked", - lastSuccessAt: "2026-05-08T12:00:00.000Z", - lastAttemptAt: "2026-05-09T18:00:00.000Z", - lastError: "Withings activity error: 293", - connected: true, - configured: true, - legacyLastSyncedAt: "2026-05-08T12:00:00.000Z", - hasActivityScope: true, - syncHealth: { verdict: "parked", since: "2026-05-09T18:00:00.000Z" }, - }, - ], - }); - - const html = render(); - expect(html).toContain('data-state="parked"'); - expect(html).toContain("Paused"); - expect(html).toContain("Paused: reconnect"); - expect(html).toContain('data-testid="withings-parked-banner"'); + expect(html).toMatch( + /data-slot="integration-health-badge"[^>]*data-state="action_required"/, + ); expect(html).toContain('data-testid="withings-resume-button"'); - // The lastError is surfaced under the pill so the operator sees - // the contract-mismatch reason without opening Audit Log. - expect(html).toContain("Withings activity error: 293"); + expect(count(html, 'data-action="resume"')).toBe(1); + expect(html).not.toContain('data-testid="withings-parked-banner"'); + expect(html).not.toContain("Withings activity error: 293"); }); it("no integration card paints a divider under its header", () => { - setIntegrationStatus({ - threshold: 3, - integrations: [ - { - integration: "withings", - state: "connected", - lastSuccessAt: "2026-05-09T18:00:00.000Z", - lastAttemptAt: "2026-05-09T18:00:00.000Z", - lastError: null, - connected: true, - configured: true, - legacyLastSyncedAt: "2026-05-09T18:00:00.000Z", - hasActivityScope: true, - syncHealth: { verdict: "fresh", since: null }, - }, - ], - }); - - const html = render(); - // The `
    ` under the header existed only in `integrations/` — nine - // cards carrying a rule no other Settings card has. The card's own gap - // does the separating now, identically here and everywhere else. - expect(count(html, 'data-testid="integration-card-divider"')).toBe(0); + setIntegrationStatus(envelopeWith({ health: health() })); + expect(count(render(), 'data-testid="integration-card-divider"')).toBe(0); }); - // v1.17.1 — every integration card carries the same discreet "Setup guide" - // doc-link pointing at its runbook under the single shared docs base. The - // affordance is one family across all six providers. - it("every integration card carries the shared Setup-guide doc-link", () => { + // Every card that is not connected yet carries the same discreet "Setup + // guide" link to its runbook under the single shared docs base. Once + // connected, the link moves into the card's menu. + it("every not-connected integration card carries the shared Setup-guide link", () => { setIntegrationStatus({ threshold: 3, - integrations: [ - { - integration: "withings", - state: "connected", - lastSuccessAt: "2026-05-09T18:00:00.000Z", - lastAttemptAt: "2026-05-09T18:00:00.000Z", - lastError: null, - connected: true, - configured: true, - legacyLastSyncedAt: "2026-05-09T18:00:00.000Z", - hasActivityScope: true, - }, - ], + integrations: PROVIDERS.map(disconnected), }); const html = render(); - // One setup-guide link per card → eight providers (the Garmin info note is - // not an OAuth card and carries its own docs link, not a setup-guide). expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8); - for (const provider of [ - "withings", - "whoop", - "fitbit", - "google-health", - "polar", - "oura", - "strava", - "nightscout", - ]) { + for (const provider of PROVIDERS) { expect(html).toContain(`data-testid="${provider}-setup-guide"`); expect(html).toContain( `href="https://docs.healthlog.dev/integrations/${provider}"`, 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 1a483c1d5..198518449 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -14,7 +14,7 @@ type Payload = { lastSyncedAt: string | null; lastSyncTrigger?: "foreground" | "background" | "push" | null; lastBackgroundSyncAt?: string | null; - syncHealth?: { verdict: string; since: string | null }; + health?: ConnectionHealth | null; metricFreshness?: Array<{ type: string; lastSeenAt: string; @@ -38,7 +38,10 @@ vi.mock("@tanstack/react-query", () => ({ }), })); +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; + import { AppleHealthCard, ArrivalDetail } from "../apple-health-card"; +import { healthFixture } from "./health-fixture"; function render(locale: Locale = "en") { return renderToStaticMarkup( @@ -56,76 +59,62 @@ describe("", () => { }); it("explains that live sync uses the iOS app rather than OAuth", () => { - statusPayload = { - lastSyncedAt: null, - syncHealth: { verdict: "pending_first_sync", since: null }, - }; + statusPayload = { lastSyncedAt: null, health: null }; const html = render(); expect(html).toContain('data-testid="apple-health-card"'); + expect(html).toContain('data-state="not_connected"'); expect(html).toContain("HealthLog iOS app"); expect(html).toContain("not from a web or OAuth connection"); expect(html).toContain("Settings → Apple Health"); expect(html).toContain("when iOS grants background time"); - expect(html).not.toContain(">Connected<"); + expect(html).toContain("Not connected."); + expect(html).not.toContain('data-slot="integration-health-badge"'); }); - /** - * The defect this card carried: any `lastSyncedAt` at all painted the green - * "data received" chip, so a pipe dead for three weeks looked exactly like - * one that delivered this morning. The verdict now decides. - */ - it("shows a week-old delivery as stale, not as data received", () => { + it("says when data stopped and what to do, from the server's health", () => { const eightDaysAgo = new Date( Date.now() - 8 * 24 * 60 * 60 * 1000, ).toISOString(); statusPayload = { lastSyncedAt: eightDaysAgo, - syncHealth: { verdict: "stale", since: eightDaysAgo }, + health: healthFixture({ + state: "degraded", + reason: "no_new_data", + since: eightDaysAgo, + lastDataAt: eightDaysAgo, + }), }; const html = render(); - expect(html).toContain('data-testid="apple-health-status"'); - expect(html).toContain('data-state="stale"'); - expect(html).toContain("no recent data"); - // And it says what to do about it. - expect(html).toContain('data-testid="apple-health-stale"'); - expect(html).not.toContain('data-state="connected"'); + expect(html).toMatch( + /data-slot="integration-health-badge"[^>]*data-state="degraded"/, + ); + expect(html).toContain("Delayed"); + // Apple Health's own variant: the iPhone app is what moves the data. + expect(html).toContain("Open HealthLog on your iPhone once."); }); - it("shows a recent delivery as connected with its relative time", () => { + it("reads a recent delivery as working", () => { const anHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); statusPayload = { lastSyncedAt: anHourAgo, - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture({ lastDataAt: anHourAgo }), }; const html = render(); - expect(html).toContain('data-state="connected"'); - expect(html).toMatch(/1\s+h\s+ago/); - expect(html).not.toContain('data-testid="apple-health-stale"'); + expect(html).toContain('data-state="ok"'); + expect(html).toContain("Data is arriving."); + expect(html).toContain("Last data: today,"); }); - it("waits for the first delivery rather than claiming a connection", () => { - statusPayload = { - lastSyncedAt: null, - syncHealth: { verdict: "pending_first_sync", since: null }, - }; - - const html = render(); - - expect(html).toContain('data-state="pending-setup"'); - expect(html).toContain("Waiting for first data"); - expect(html).not.toContain('data-state="connected"'); - }); - - it("renders per-metric freshness so one dead permission is visible", () => { + it("keeps per-metric freshness in the details so one dead permission stays findable", () => { statusPayload = { lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), metricFreshness: [ { type: "RESPIRATORY_RATE", @@ -143,26 +132,14 @@ describe("", () => { }; const html = render(); + const details = html.slice(html.indexOf('data-slot="integration-details"')); - expect(html).toContain('data-slot="metric-freshness"'); - // The collapsed summary already names the problem — the signal must not - // hide behind a click. - expect(html).toContain("quiet"); - }); - - it("carries no card divider — the card gap does that job", () => { - statusPayload = { - lastSyncedAt: null, - syncHealth: { verdict: "pending_first_sync", since: null }, - }; - expect(render()).not.toContain('data-testid="integration-card-divider"'); + expect(details).toContain('data-slot="metric-freshness"'); + expect(details).toContain("1 quiet"); }); it("links to the existing one-shot Apple Health import fallback", () => { - statusPayload = { - lastSyncedAt: null, - syncHealth: { verdict: "pending_first_sync", since: null }, - }; + statusPayload = { lastSyncedAt: null, health: null }; const html = render(); @@ -173,14 +150,15 @@ describe("", () => { expect(html).toContain("Open one-shot import"); }); - it("claims no status at all while the read is in flight", () => { + it("paints the card and its header while the read is in flight, and no state", () => { statusPayload = undefined; statusLoading = true; const html = render(); - expect(html).not.toContain('data-testid="apple-health-status"'); - expect(html).not.toContain("Connected"); + expect(html).toContain('data-state="loading"'); + expect(html).not.toContain('data-slot="integration-health-badge"'); + expect(html).not.toContain("Not connected."); }); it("treats a failed status read as an error line, not a status", () => { @@ -189,9 +167,8 @@ describe("", () => { const html = render(); expect(html).toContain("Apple Health status unavailable"); - expect(html).toContain('data-testid="integration-error-message"'); - expect(html).not.toContain('data-testid="apple-health-status"'); - expect(html).not.toContain("Connected"); + expect(html).not.toContain('data-slot="integration-health-badge"'); + expect(html).not.toContain("Not connected."); }); it("defines every card key in all six locale catalogs", () => { @@ -203,7 +180,6 @@ describe("", () => { "backgroundStep", "importNote", "importAction", - "staleNote", ] as const; for (const catalog of [en, de, es, fr, itMessages, pl]) { @@ -212,8 +188,6 @@ describe("", () => { expect(appleHealth[key]).toBeTypeOf("string"); expect(appleHealth[key].length).toBeGreaterThan(0); } - // The status copy the pill now owns is gone from the catalog; only the - // failed-read line survives here. expect(Object.keys(appleHealth.status).sort()).toEqual(["unavailable"]); } }); @@ -221,10 +195,7 @@ describe("", () => { it.each(["en", "de", "es", "fr", "it", "pl", "ko"] as const)( "renders localized Apple Health copy for %s without leaking keys", (locale) => { - statusPayload = { - lastSyncedAt: null, - syncHealth: { verdict: "pending_first_sync", since: null }, - }; + statusPayload = { lastSyncedAt: null, health: null }; const html = render(locale); @@ -246,7 +217,7 @@ describe(" — delivery diagnostic (#586)", () => { lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), lastSyncTrigger: "background", lastBackgroundSyncAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), }; const html = render(); @@ -261,7 +232,7 @@ describe(" — delivery diagnostic (#586)", () => { lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), lastSyncTrigger: "foreground", lastBackgroundSyncAt: null, - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), }; const html = render(); @@ -277,7 +248,7 @@ describe(" — delivery diagnostic (#586)", () => { lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), lastSyncTrigger: null, lastBackgroundSyncAt: null, - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), }; const html = render(); @@ -287,15 +258,16 @@ describe(" — delivery diagnostic (#586)", () => { ); }); - it("says there is nothing to report when no sync has ever arrived", () => { + it("shows the setup steps instead of an empty delivery report before any sync", () => { statusPayload = { lastSyncedAt: null, - syncHealth: { verdict: "pending_first_sync", since: null }, + health: null, }; const html = render(); - expect(html).toContain("No sync has arrived yet"); + expect(html).toContain("Turn on live sync"); + expect(html).not.toContain('data-testid="apple-health-delivery"'); expect(html).not.toContain("Last background sync"); }); }); @@ -312,7 +284,7 @@ describe(" — sync progress (#778)", () => { it("shows the accepted-row count and the oldest reading reached", () => { statusPayload = { lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), syncProgress: { recordsAccepted: 12345, oldestMeasuredAt: "2019-05-04T06:00:00.000Z", @@ -334,7 +306,7 @@ describe(" — sync progress (#778)", () => { it("reads as flowing while batches keep arriving", () => { statusPayload = { lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null }, }; @@ -348,7 +320,7 @@ describe(" — sync progress (#778)", () => { it("waits honestly instead of inventing throttle state when batches pause", () => { statusPayload = { lastSyncedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null }, }; @@ -364,7 +336,7 @@ describe(" — sync progress (#778)", () => { it("renders no progress section before anything has arrived", () => { statusPayload = { lastSyncedAt: null, - syncHealth: { verdict: "pending_first_sync", since: null }, + health: null, syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null }, }; @@ -377,7 +349,7 @@ describe(" — sync progress (#778)", () => { it("renders no progress section when the server read failed", () => { statusPayload = { lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), syncProgress: null, }; @@ -472,7 +444,7 @@ describe(" — per-type arrivals (#1173)", () => { it("titles the list by data type", () => { statusPayload = { lastSyncedAt: hoursAgo(1), - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), metricFreshness: [{ ...base }], }; expect(render()).toContain("By data type"); diff --git a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx index c06fa4d22..222798d89 100644 --- a/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx +++ b/src/components/settings/integrations/__tests__/connections-panel-coverage.test.tsx @@ -49,6 +49,7 @@ vi.mock("@tanstack/react-query", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { ConnectionsPanel } from "../connections-panel"; import { INTEGRATION_DISPLAY_NAMES } from "../integration-fallback-row"; +import { healthFixture } from "./health-fixture"; const MONTH_AGO = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(); @@ -64,14 +65,16 @@ const CARD_LESS_KEY = "example-provider"; function entry(integration: string) { return { integration, - state: "error_transient", - lastSuccessAt: MONTH_AGO, - lastAttemptAt: MONTH_AGO, lastError: null, configured: true, connected: true, available: true, - syncHealth: { verdict: "stalled", since: MONTH_AGO }, + health: healthFixture({ + state: "degraded", + reason: "sync_stalled", + since: MONTH_AGO, + lastDataAt: MONTH_AGO, + }), metricFreshness: [], }; } @@ -117,7 +120,7 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => { } }); - it("gives a card-less provider a fallback row with the stalled pill", () => { + it("gives a card-less provider the shared card, read-only, with its server state", () => { // The shape the mechanism exists for. With no display name registered the // row falls back to the raw key, which is still an honest surface — far // better than the provider rendering nowhere at all. @@ -130,9 +133,15 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => { expect(html).toContain('data-testid="integration-fallback-row"'); expect(html).toContain(`data-integration="${CARD_LESS_KEY}"`); expect(html).toContain(CARD_LESS_KEY); - expect(html).toContain('data-state="stalled"'); - expect(html).toContain("Sync stopped"); - expect(html).toContain("Last attempt"); + const row = html.slice( + html.indexOf('data-testid="integration-fallback-row"'), + ); + expect(row).toMatch( + /data-slot="integration-health-badge"[^>]*data-state="degraded"/, + ); + expect(row).toContain("HealthLog has not synced since"); + // Read-only: no menu and no button for a dropped integration. + expect(html).not.toContain(`data-testid="${CARD_LESS_KEY}-menu"`); }); it("renders no fallback row when every entry is card-backed", () => { @@ -149,12 +158,9 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => { integrations: [ { ...entry(CARD_LESS_KEY), - state: "disconnected", - lastSuccessAt: null, - lastAttemptAt: null, configured: false, connected: false, - syncHealth: { verdict: "disconnected", since: null }, + health: null, }, ], }; diff --git a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx index 21668bf1d..a30661df3 100644 --- a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx +++ b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx @@ -1,23 +1,23 @@ /** - * Nightscout parity + correctness. + * Nightscout on the shared integration card. * - * 1. A `parked` verdict renders the warning banner + reconnect button - * (matching the WHOOP card's parked treatment, byte-for-byte classes). - * 2. A healthy connection renders the shared TestConnectionButton AND the - * connect→data link to /insights/blood-glucose. - * 3. The card reads the consolidated envelope — it was the last card firing - * its own status round-trip — so a disconnect invalidates that one key. + * 1. A paused connection offers "Resume", which opens the address form (there + * is no OAuth round-trip to re-run). + * 2. A healthy connection keeps sync, test, the data link and disconnect in + * the menu, and disconnect asks first. + * 3. The card reads the consolidated envelope, so a disconnect invalidates + * that one key. + * 4. An unapproved private origin is named for what it is, an operator + * action, without echoing the stored error text. */ import { describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { queryKeys } from "@/lib/query-keys"; -// Capture the disconnect mutation's onSuccess so the test can assert which -// query keys it invalidates. const invalidateSpy = vi.fn(); -type OnSuccess = () => void; -const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null }; +const capturedDisconnectOnSuccess: { fn: (() => void) | null } = { fn: null }; vi.mock("@tanstack/react-query", () => ({ useMutation: ({ onSuccess }: { onSuccess?: () => void }) => { @@ -28,11 +28,30 @@ vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: invalidateSpy }), })); +let lastCardProps: IntegrationCardProps | null = null; +vi.mock("../integration-card", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IntegrationCard: (props: IntegrationCardProps) => { + lastCardProps = props; + return createElement(actual.IntegrationCard, props); + }, + }; +}); + import { I18nProvider } from "@/lib/i18n/context"; +import type { IntegrationCardProps } from "../integration-card"; import { NightscoutCard } from "../nightscout-card"; import type { IntegrationStatusViewModel } from "../shared"; +import { healthFixture } from "./health-fixture"; -function render(viewModel?: Partial) { +/** The envelope carries more than the card declares; the card must ignore it. */ +type Envelope = Partial & { + lastError?: string | null; +}; + +function render(viewModel?: Envelope) { return renderToStaticMarkup( ) { viewModel ? ({ integration: "nightscout", - state: "connected", - lastSuccessAt: null, - lastAttemptAt: null, lastError: null, ...viewModel, } as IntegrationStatusViewModel) @@ -53,121 +69,126 @@ function render(viewModel?: Partial) { ); } -describe("NightscoutCard — parked + test + data-link + invalidation", () => { - it("renders the parked banner + reconnect button when the verdict is parked", () => { +function menuIds(): string[] { + return (lastCardProps?.menu ?? []).map((item) => item.id); +} + +describe("NightscoutCard on the shared card", () => { + it("offers Resume on a paused connection, and Resume opens the address form", () => { const html = render({ connected: true, configured: true, - state: "parked", - lastError: "Nightscout unreachable", - syncHealth: { verdict: "parked", since: null }, + health: healthFixture({ + state: "action_required", + reason: "paused_after_errors", + since: "2026-10-12T14:00:00.000Z", + action: "resume", + }), }); - expect(html).toContain('data-state="parked"'); - expect(html).toContain('data-testid="nightscout-parked-banner"'); - expect(html).toContain('data-testid="nightscout-resume-button"'); - expect(html).toContain("border-warning/30 bg-warning/10"); + expect(html).toMatch( + /data-slot="integration-health-badge"[^>]*data-state="action_required"/, + ); + expect(html).toContain('data-action="resume"'); + expect(html).toContain("Nightscout is paused after failing since"); + expect(lastCardProps?.primary?.onResume).toBeTypeOf("function"); + // The form is closed until the button asks for it. + expect(html).not.toContain('id="nightscout-url"'); }); - it("renders the test-connection button + data link when connected", () => { - const html = render({ + it("keeps sync, test, the data link and disconnect in the menu", () => { + render({ connected: true, configured: true, - lastSuccessAt: "2026-06-01T00:00:00.000Z", - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), + metricFreshness: [], }); - expect(html).toContain("Test connection"); - expect(html).toContain('data-testid="nightscout-data-link"'); - expect(html).toContain('href="/insights/blood-glucose"'); + expect(menuIds()).toEqual([ + "sync", + "test", + "view-data", + "credentials", + "setup-guide", + "disconnect", + ]); + const viewData = lastCardProps?.menu?.find((i) => i.id === "view-data"); + expect(viewData?.href).toBe("/insights/blood-glucose"); + const disconnect = lastCardProps?.menu?.find((i) => i.id === "disconnect"); + expect(disconnect?.destructive).toBe(true); + expect(disconnect?.confirm?.title).toBeTruthy(); }); - it("carries no card divider — the card gap does that job", () => { + it("puts the per-metric list in the details, stale rows included", () => { const html = render({ connected: true, configured: true, - syncHealth: { verdict: "fresh", since: null }, - }); - expect(html).not.toContain('data-testid="integration-card-divider"'); - }); - - it("renders the per-metric freshness disclosure with a stale row", () => { - const html = render({ - connected: true, - configured: true, - syncHealth: { verdict: "fresh", since: null }, + health: healthFixture(), metricFreshness: [ { type: "BLOOD_GLUCOSE", - lastSeenAt: "2026-06-01T00:00:00.000Z", + lastSeenAt: new Date(Date.now() - 3 * 86_400_000).toISOString(), stale: true, }, ], }); - expect(html).toContain('data-slot="metric-freshness"'); - expect(html).toContain("quiet"); + const details = html.slice(html.indexOf('data-slot="integration-details"')); + expect(details).toContain('data-metric="BLOOD_GLUCOSE"'); + expect(details).toContain('data-state="stale"'); }); it("disconnect invalidates the envelope the card actually reads", () => { + render({ connected: true, configured: true, health: healthFixture() }); invalidateSpy.mockClear(); - capturedDisconnectOnSuccess.fn = null; - render({ - connected: true, - configured: true, - syncHealth: { verdict: "fresh", since: null }, + capturedDisconnectOnSuccess.fn?.(); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: queryKeys.integrationsStatus(), }); - const onSuccess = capturedDisconnectOnSuccess.fn as (() => void) | null; - expect(typeof onSuccess).toBe("function"); - onSuccess?.(); - const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey); - expect(invalidatedKeys).toContainEqual(queryKeys.integrationsStatus()); - }); -}); - -describe("NightscoutCard — sync-now action", () => { - it("offers the sync action on a connected instance", () => { - const html = render({ - connected: true, - configured: true, - syncHealth: { verdict: "fresh", since: null }, - }); - expect(html).toContain('data-testid="nightscout-sync"'); - expect(html).toContain("Sync now"); }); - it("does not offer it before an instance is configured", () => { - expect( - render({ - connected: false, - configured: false, - state: "disconnected", - syncHealth: { verdict: "disconnected", since: null }, - }), - ).not.toContain('data-testid="nightscout-sync"'); + it("shows the address form and Connect while not connected", () => { + const html = render({ connected: false, configured: false, health: null }); + expect(html).toContain('data-state="not_connected"'); + expect(html).toContain('id="nightscout-url"'); + expect(html).toContain('data-testid="nightscout-connect"'); + expect(html).toContain('data-testid="nightscout-setup-guide"'); }); }); describe("NightscoutCard — operator-owned private access", () => { it("does not offer an ordinary user a private-host enforcement bypass", () => { - const html = render({ - connected: false, - configured: false, - state: "disconnected", - syncHealth: { verdict: "disconnected", since: null }, - }); + const html = render({ connected: false, configured: false, health: null }); expect(html).not.toContain('id="nightscout-private"'); expect(html).not.toContain("This site runs on a private network"); expect(html).not.toContain("Enable only if your Nightscout instance"); }); - it("shows a redacted operator-action state for an unapproved legacy private connection", () => { + it("names the operator action for an unapproved private origin without echoing the error", () => { const html = render({ connected: true, configured: true, - state: "error_reauth", + // Still on the wire for one release; the card must not show it. lastError: "private_origin_not_approved: http://10.0.0.4:1337/api/v1/entries.json?token=ns-secret", - syncHealth: { verdict: "failing", since: null }, - allowPrivateHost: true, + health: healthFixture({ + state: "degraded", + reason: "provider_errors", + since: "2026-10-14T18:40:00.000Z", + diagnostics: { + ledgerState: "error_transient", + failingLegs: ["sync"], + failingSince: "2026-10-14T18:40:00.000Z", + lastAttemptAt: "2026-10-15T09:15:00.000Z", + verdict: "failing", + recentCalls: [ + { + endpoint: "sync", + status: "persistent", + at: "2026-10-15T09:15:00.000Z", + errorCode: "private_origin_not_approved", + }, + ], + }, + }), }); expect(html).toContain( @@ -176,35 +197,27 @@ describe("NightscoutCard — operator-owned private access", () => { expect(html).toContain( "Private Nightscout access requires server operator approval.", ); - expect(html).toContain( - "Ask your server operator to approve this exact Nightscout origin.", - ); - expect(html).not.toContain("private_origin_not_approved"); + // The short code appears in the diagnostics block, the stored message + // nowhere: no address, no token. + expect(html).not.toContain("private_origin_not_approved:"); expect(html).not.toContain("10.0.0.4"); expect(html).not.toContain("ns-secret"); - expect(html).not.toContain('data-testid="nightscout-sync"'); - expect(html).not.toContain("Test connection"); - expect(html).toContain("Disconnect"); + // Sync and test cannot help until the operator approves the origin. + expect(menuIds()).not.toContain("sync"); + expect(menuIds()).not.toContain("test"); + expect(menuIds()).toContain("disconnect"); }); - it.each([ - ["public", false], - ["operator-approved private", true], - ])("keeps a healthy %s connection usable", (_label, allowPrivateHost) => { + it("keeps a healthy connection usable", () => { const html = render({ connected: true, configured: true, - state: "connected", lastError: null, - syncHealth: { verdict: "fresh", since: null }, - // Compatibility/display metadata only; it is not the authority asserted - // by this positive flow. - allowPrivateHost, + health: healthFixture(), }); - expect(html).toContain('data-testid="nightscout-sync"'); - expect(html).toContain("Test connection"); - expect(html).toContain("Disconnect"); + expect(menuIds()).toContain("sync"); + expect(menuIds()).toContain("test"); expect(html).not.toContain( 'data-testid="nightscout-private-operator-required"', ); diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx deleted file mode 100644 index 12462e77d..000000000 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -/** - * v1.17.0 — interaction-parity check for the shared OAuth integration card - * (Polar / Oura). The new cards must match the existing WHOOP card's - * parked-state + test-connection + connect→data treatment: - * - * 1. A `parked` status renders the warning banner + reconnect button. - * 2. A `connected` status renders the shared TestConnectionButton (its - * "Test connection" affordance) AND the connect→data link. - * 3. The data link resolves to the provider's insight surface. - */ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { Watch } from "lucide-react"; - -// The card reads the consolidated envelope only — its per-card status fetch is -// gone, so every fixture is a view-model. -vi.mock("@tanstack/react-query", () => ({ - useMutation: () => ({ mutate: vi.fn(), isPending: false }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { OAuthProviderCard } from "../oauth-provider-card"; - -type ViewModel = Parameters[0]["viewModel"]; - -function render({ - credentials = false, - viewModel, -}: { - credentials?: boolean; - viewModel?: ViewModel; -} = {}) { - return renderToStaticMarkup( - - - , - ); -} - -describe("OAuthProviderCard — parked + test + data-link parity", () => { - it("renders the parked banner + reconnect button when state is parked", () => { - const html = render({ - viewModel: { - connected: true, - configured: true, - available: true, - state: "parked", - lastSuccessAt: null, - lastError: "Polar grant expired", - syncHealth: { verdict: "parked", since: null }, - }, - }); - expect(html).toContain('data-state="parked"'); - expect(html).toContain('data-testid="polar-parked-banner"'); - expect(html).toContain('data-testid="polar-resume-button"'); - expect(html).toContain("Paused: reconnect"); - // The parked banner uses the same warning treatment as the WHOOP card. - expect(html).toContain("border-warning/30 bg-warning/10"); - }); - - it("renders the test-connection button + data link when connected", () => { - const html = render({ - viewModel: { - connected: true, - configured: true, - available: true, - state: "connected", - lastSuccessAt: "2026-06-01T00:00:00.000Z", - lastError: null, - syncHealth: { verdict: "fresh", since: null }, - }, - }); - // The shared TestConnectionButton surfaces its "Test connection" label. - expect(html).toContain("Test connection"); - // connect→data link points at the provider's insight surface. - expect(html).toContain('data-testid="polar-data-link"'); - expect(html).toContain('href="/insights/sleep"'); - }); - - it("reads off the passed view-model — the per-card fetch is gone", () => { - // There is no `useQuery` left in this component: the envelope is the only - // source. A per-provider status response carries no verdict, so falling - // back to it would have painted a status the server never resolved. - const html = render({ - viewModel: { - connected: true, - configured: true, - available: true, - state: "connected", - lastSuccessAt: "2026-06-01T00:00:00.000Z", - lastError: null, - syncHealth: { verdict: "fresh", since: null }, - }, - }); - expect(html).toContain('data-testid="polar-data-link"'); - expect(html).toContain("Test connection"); - }); - - it("does not render the data link or test button when disconnected", () => { - const html = render({ - viewModel: { - connected: false, - configured: false, - available: true, - syncHealth: { verdict: "disconnected", since: null }, - }, - }); - expect(html).not.toContain('data-testid="polar-data-link"'); - expect(html).not.toContain("Test connection"); - // The connect CTA stands in instead. - expect(html).toContain('data-testid="polar-connect"'); - }); -}); - -describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => { - it("renders the credentials form only when the `credentials` prop is set", () => { - const viewModel: ViewModel = { - connected: false, - configured: false, - available: true, - hasOwnCredentials: false, - syncHealth: { verdict: "disconnected", since: null }, - }; - // Opt-in: the BYO client-id/secret form + save button appear. - const withForm = render({ credentials: true, viewModel }); - expect(withForm).toContain('data-testid="polar-credentials"'); - expect(withForm).toContain('id="polar-clientid"'); - expect(withForm).toContain('id="polar-secret"'); - - // Default: no credential inputs (env-only behaviour preserved). - const withoutForm = render({ viewModel }); - expect(withoutForm).not.toContain('data-testid="polar-credentials"'); - expect(withoutForm).not.toContain('id="polar-clientid"'); - }); - - it("shows the saved-placeholder once the user has stored their own pair", () => { - const html = render({ - credentials: true, - viewModel: { - connected: true, - configured: true, - available: true, - hasOwnCredentials: true, - state: "connected", - lastSuccessAt: null, - lastError: null, - syncHealth: { verdict: "pending_first_sync", since: null }, - }, - }); - expect(html).toContain("Saved. Enter new to replace"); - }); -}); - -describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => { - it("shows the callback URL guide before the user has BYO credentials", () => { - const html = render({ - credentials: true, - viewModel: { - connected: false, - configured: false, - available: true, - hasOwnCredentials: false, - syncHealth: { verdict: "disconnected", since: null }, - }, - }); - expect(html).toContain('data-testid="polar-redirect-guide"'); - expect(html).toContain('data-testid="polar-redirect-uri"'); - expect(html).toContain("https://app.example/api/polar/callback"); - }); - - it("hides the guide once the user has stored their own credentials", () => { - const html = render({ - credentials: true, - viewModel: { - connected: true, - configured: true, - available: true, - hasOwnCredentials: true, - state: "connected", - lastSuccessAt: null, - lastError: null, - syncHealth: { verdict: "fresh", since: null }, - }, - }); - expect(html).not.toContain('data-testid="polar-redirect-guide"'); - }); -}); - -/** - * v1.32.28 — sync now. The route without the button would be an endpoint with - * no caller; the button is what makes the manual sync a feature rather than an - * API detail. Rendered only once the provider is connected, in the same action - * row as Test connection. - */ -describe("OAuthProviderCard — sync-now action", () => { - it("offers the sync action on a connected provider", () => { - const html = render({ - viewModel: { - connected: true, - configured: true, - available: true, - state: "connected", - lastSuccessAt: null, - lastError: null, - syncHealth: { verdict: "fresh", since: null }, - }, - }); - expect(html).toContain('data-testid="polar-sync"'); - expect(html).toContain("Sync now"); - }); - - it("does not offer it before the provider is connected", () => { - expect( - render({ - viewModel: { - connected: false, - configured: true, - available: true, - syncHealth: { verdict: "disconnected", since: null }, - }, - }), - ).not.toContain('data-testid="polar-sync"'); - }); -}); - -describe("OAuthProviderCard — Oura scope note", () => { - const scopeGap = { - connected: true, - configured: true, - available: true, - state: "connected" as const, - lastSuccessAt: "2026-10-14T00:00:00.000Z", - lastError: null, - syncHealth: { verdict: "fresh" as const, since: null }, - scopeReconnect: ["spo2" as const, "vo2max" as const], - }; - - function renderOura(viewModel: ViewModel) { - return renderToStaticMarkup( - - - , - ); - } - - it("asks for one reconnect beside a healthy pill, not an error", () => { - const html = renderOura(scopeGap); - expect(html).toContain('data-testid="oura-scope-note"'); - expect(html).toContain("Reconnect Oura once to allow SpO2 and VO2 max."); - expect(html).not.toContain('data-testid="oura-error"'); - }); - - it("stays away from the other OAuth providers", () => { - expect(render({ viewModel: scopeGap })).not.toContain("scope-note"); - }); -}); diff --git a/src/components/settings/integrations/__tests__/pill-mapper.test.tsx b/src/components/settings/integrations/__tests__/pill-mapper.test.tsx deleted file mode 100644 index f67be81d0..000000000 --- a/src/components/settings/integrations/__tests__/pill-mapper.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/** - * One verdict, one pill mapping. - * - * There used to be three `pillStateFor` implementations — one per card family — - * and they disagreed with each other. The same ledger state painted red on the - * Withings card and orange on the Polar card, on the same page. The server now - * resolves the verdict once; this pins that the client projects it once, and - * that a fourth dialect cannot be minted without someone editing this file. - */ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { walkSourceFiles } from "@/__tests__/helpers/source-files"; - -import type { SyncVerdict } from "@/lib/integrations/sync-verdict"; -import { - pickStatus, - pillFailurePropsFor, - pillStateForVerdict, -} from "../shared"; - -describe("pillStateForVerdict — the verdict→pill table", () => { - const table: Array<[SyncVerdict | undefined, string]> = [ - ["fresh", "connected"], - ["stale", "stale"], - ["stalled", "stalled"], - ["failing", "warning"], - ["reauth_required", "error"], - ["parked", "parked"], - ["pending_first_sync", "pending-setup"], - ["disconnected", "disconnected"], - // Still loading: the card renders its neutral default rather than - // guessing at a status it has not been told. - [undefined, "disconnected"], - ]; - - it.each(table)("maps %s to the %s pill", (verdict, expected) => { - expect(pillStateForVerdict(verdict)).toBe(expected); - }); - - /** - * The decision this release makes and must not silently lose: **red means - * "your action fixes this"**. Reconnecting cannot repair an upstream 503 or - * a cron that stopped running, so neither of those may reach the destructive - * pill. - */ - it("reserves the error pill for the states a reconnect actually fixes", () => { - const red = table - .filter(([, state]) => state === "error") - .map(([verdict]) => verdict); - expect(red).toEqual(["reauth_required"]); - expect(pillStateForVerdict("failing")).not.toBe("error"); - expect(pillStateForVerdict("stalled")).not.toBe("error"); - }); -}); - -describe("pillFailurePropsFor", () => { - it("projects the active bucket and envelope threshold onto the pill", () => { - const status = pickStatus( - { - threshold: 3, - integrations: [ - { - integration: "withings", - state: "error_transient", - lastSuccessAt: null, - lastAttemptAt: "2026-07-28T12:00:00.000Z", - lastError: "upstream unavailable", - consecutiveFailuresByKind: { - transient: 2, - reauth_required: 0, - persistent: 1, - }, - }, - ], - }, - "withings", - ); - - expect(pillFailurePropsFor(status)).toEqual({ - failureCount: 2, - failureThreshold: 3, - }); - }); - - it("returns no ratio for a healthy zero-count status", () => { - const status = pickStatus( - { - threshold: 3, - integrations: [ - { - integration: "withings", - state: "connected", - lastSuccessAt: "2026-07-28T12:00:00.000Z", - lastAttemptAt: "2026-07-28T12:00:00.000Z", - lastError: null, - consecutiveFailuresByKind: { - transient: 0, - reauth_required: 0, - persistent: 0, - }, - }, - ], - }, - "withings", - ); - - expect(pillFailurePropsFor(status)).toEqual({}); - }); -}); - -describe("structural guard — no second pill dialect", () => { - const SRC = join(process.cwd(), "src"); - - function sourceFiles(): string[] { - return walkSourceFiles(SRC, { floor: 3000 }) - .filter((p) => !p.startsWith("generated/")) - .filter((p) => !p.includes("__tests__")) - .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); - } - - /** - * The one module allowed to define a status→pill mapping. A tripwire, not a - * proof: it cannot show the mapping is right, only that nobody added a - * second one without editing this file. - */ - const MAPPER_HOME = "components/settings/integrations/shared.tsx"; - - it("defines the mapping in exactly one module", () => { - const definers = sourceFiles().filter((rel) => - /function\s+pillStateFor\w*\s*\(/.test( - readFileSync(join(SRC, rel), "utf8"), - ), - ); - expect(definers).toEqual([MAPPER_HOME]); - }); -}); diff --git a/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx b/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx deleted file mode 100644 index 6a109ab19..000000000 --- a/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * The two "withheld by contract" notes: the Withings card names what the - * account's API plan refuses, the Oura card asks for one reconnect when the - * grant lacks a scope. Both render nothing when nothing is withheld, and both - * render the server-resolved list rather than deriving one. - */ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { WithingsPlanNote } from "../withings-plan-note"; -import { OuraScopeNote } from "../oura-scope-note"; - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -describe("", () => { - it("lists the withheld readings and links the Withings plans page", () => { - const html = render( - , - ); - expect(html).toContain('data-testid="withings-plan-note"'); - expect(html).toContain("SpO2, vascular age, and ECG and AFib"); - expect(html).toContain("Weight, blood pressure, body composition"); - expect(html).toContain( - "https://static.withings.com/content/api/plans/plans.html", - ); - }); - - it("speaks the reader's language", () => { - const html = render(, "de"); - expect(html).toContain("Gefäßalter"); - expect(html).toContain("kostenpflichtigen API-Plan"); - }); - - it("renders nothing when the plan withholds nothing", () => { - expect(render()).toBe(""); - expect(render()).toBe(""); - }); -}); - -describe("", () => { - it("asks for one reconnect naming the collections the grant lacks", () => { - const html = render( - , - ); - expect(html).toContain('data-testid="oura-scope-note"'); - expect(html).toContain( - "Reconnect Oura once to allow SpO2, VO2 max, cardiovascular age, and resilience.", - ); - expect(html).toContain('data-testid="oura-scope-reconnect"'); - }); - - it("renders nothing when the grant covers everything", () => { - expect( - render(), - ).toBe(""); - }); -}); diff --git a/src/components/settings/integrations/__tests__/whoop-card.test.tsx b/src/components/settings/integrations/__tests__/whoop-card.test.tsx deleted file mode 100644 index 6d5bc5388..000000000 --- a/src/components/settings/integrations/__tests__/whoop-card.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * v1.29.x — UX audit H2: the WHOOP card shows a compact callback-URL guide - * while the account has no BYO credentials saved, and steps out of the way - * once configured. Mirrors the OAuthProviderCard coverage for Polar/Oura. - */ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -vi.mock("@tanstack/react-query", () => ({ - useMutation: () => ({ mutate: vi.fn(), isPending: false }), - useQueryClient: () => ({ invalidateQueries: vi.fn() }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import { WhoopCard } from "../whoop-card"; -import type { IntegrationStatusViewModel } from "../shared"; - -function render( - viewModel?: Partial, - callbackUrl: string | null = "https://app.example/api/whoop/callback", -) { - return renderToStaticMarkup( - - - , - ); -} - -describe(" redirect-URI mini-guide", () => { - it("shows the callback URL guide before credentials are configured", () => { - const html = render({ connected: false, configured: false }); - expect(html).toContain('data-testid="whoop-redirect-guide"'); - expect(html).toContain('data-testid="whoop-redirect-uri"'); - expect(html).toContain("https://app.example/api/whoop/callback"); - }); - - it("falls back to the fixed path when the server has no redirect URI", () => { - const html = render({ connected: false, configured: false }, null); - expect(html).toContain('data-testid="whoop-redirect-uri"'); - expect(html).toContain("/api/whoop/callback"); - expect(html).not.toContain("undefined/api/whoop/callback"); - }); - - it("hides the guide once credentials are configured", () => { - const html = render({ connected: false, configured: true }); - expect(html).not.toContain('data-testid="whoop-redirect-guide"'); - }); -}); diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index 403594b6f..85181296e 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -1,36 +1,29 @@ "use client"; /** - * Apple Health — the one integration with no ledger row and no cron. + * Apple Health: the one integration with no ledger row and no cron. * - * The iOS app pushes; nothing here polls. The card used to derive its status - * from the mere presence of `lastSyncedAt`, so any timestamp at all painted the - * green "data received" chip — a pipe dead for three weeks looked exactly like - * one that delivered this morning. It now reads the server's verdict, which - * applies a seven-day threshold to the same timestamp, and renders the shared - * pill instead of a bespoke badge so the flagship integration speaks the same - * status language as its siblings. + * The iOS app pushes; nothing here polls. The card renders the shared + * integration card from the server's `health`, so the flagship integration + * speaks the same status language as its siblings. What only Apple Health + * knows (how the last batch arrived, how far the first backfill reached, when + * each data type last came in) lives in the card's details. */ import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { HeartPulse, Upload } from "lucide-react"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { - formatRelative, - IntegrationStatusPill, -} from "@/components/settings/integration-status-pill"; +import { formatRelative } from "@/components/settings/integration-status-pill"; import { Button } from "@/components/ui/button"; import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; 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"; -import { IntegrationErrorMessage, pillStateForVerdict } from "./shared"; +import { IntegrationCard } from "./integration-card"; +import { MetricFreshnessList } from "./metric-freshness-list"; type SyncTrigger = "foreground" | "background" | "push" | "manual"; @@ -40,7 +33,8 @@ interface HealthKitStatus { lastSyncTrigger?: SyncTrigger | null; /** When data last arrived without the app being opened. */ lastBackgroundSyncAt?: string | null; - syncHealth?: SyncHealth; + /** The unified connection state; null until the phone has delivered. */ + health?: ConnectionHealth | null; /** Per type: newest sample, plus when it last arrived and how (#1173). */ metricFreshness?: HealthKitFreshnessEntry[]; /** Server-side backfill progress (#778); null when the read failed. */ @@ -188,7 +182,7 @@ export function ArrivalDetail({ data-state="received" data-trigger={trigger ?? "unknown"} > - {parts.join(" · ")} + {parts.join(", ")} ); } @@ -279,88 +273,84 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { refetchOnWindowFocus: true, }); - const verdict = status?.syncHealth?.verdict; - const pillState = pillStateForVerdict(verdict); - // A failed status read is an error line, not a status state — the card says - // nothing about the pipe it could not ask about. - const showPill = !isLoading && !isError && !!status; + const health = status?.health ?? null; + // Apple Health has nothing to connect: it counts as connected once the + // phone has delivered anything, which is exactly when the server resolves a + // health for it. A failed read is neither, so the card says it could not + // ask instead of claiming "not connected". + const connected = isError || health !== null; + + const importLink = ( + + ); return ( - - - ) : null - } - /> - -
    - {isError && ( - - )} - - {verdict === "stale" && ( -

    - {t("settings.appleHealth.staleNote")} -

    - )} - - {showPill && } - - {showPill && } - - } - /> - -
    -

    - {t("settings.appleHealth.setupTitle")} -

    -
      -
    1. {t("settings.appleHealth.permissionStep")}
    2. -
    3. {t("settings.appleHealth.backgroundStep")}
    4. -
    -
    - -
    -

    + +

    +

    + {t("settings.appleHealth.setupTitle")} +

    +
      +
    1. {t("settings.appleHealth.permissionStep")}
    2. +
    3. {t("settings.appleHealth.backgroundStep")}
    4. +
    +
    +

    {t("settings.appleHealth.importNote")}

    -
    -
    -
    + } + connectAction={importLink} + menu={[ + { + id: "import", + label: t("settings.appleHealth.importAction"), + icon: Upload, + href: "/settings/export#settings-section-import-title", + }, + ]} + details={ + status && !isError ? ( + <> + + + } + /> + + ) : undefined + } + > + {isError ? ( +

    + {t("settings.appleHealth.status.unavailable")} +

    + ) : null} + ); } diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index 52877436f..bc89bb947 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -17,23 +17,21 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card"; -import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note"; import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card"; import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row"; import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; -import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card"; -import { OuraCard } from "@/components/settings/integrations/oura-card"; -import { PolarCard } from "@/components/settings/integrations/polar-card"; -import { StravaCard } from "@/components/settings/integrations/strava-card"; +import { + OAUTH_INTEGRATIONS, + OAuthIntegrationCard, +} from "@/components/settings/integrations/oauth-integration-card"; import { pickStatus, useIntegrationStatuses, type IntegrationKey, - type IntegrationStatusViewModel, } from "@/components/settings/integrations/shared"; -import { WhoopCard } from "@/components/settings/integrations/whoop-card"; -import { WithingsCard } from "@/components/settings/integrations/withings-card"; +import { Badge } from "@/components/ui/badge"; +import { TagChip } from "@/components/ui/tag-chip"; import { useAuth } from "@/hooks/use-auth"; import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { useTranslations } from "@/lib/i18n/context"; @@ -179,32 +177,6 @@ export function oauthReasonKey( : `${base}.generic`; } -/** - * Adapt the consolidated-envelope view-model into the OAuth card's status - * shape. Returns `undefined` when the envelope hasn't loaded so the card - * renders its loading/disconnected default rather than a half-populated state. - */ -function toOAuthStatus( - vm: IntegrationStatusViewModel | undefined, -): OAuthProviderStatus | undefined { - if (!vm) return undefined; - return { - connected: vm.connected ?? false, - configured: vm.configured ?? false, - available: vm.available ?? false, - hasOwnCredentials: vm.hasOwnCredentials, - state: vm.state, - lastSuccessAt: vm.lastSuccessAt, - lastAttemptAt: vm.lastAttemptAt, - lastError: vm.lastError, - legacyLastSyncedAt: vm.legacyLastSyncedAt, - consecutiveFailuresByKind: vm.consecutiveFailuresByKind, - failureThreshold: vm.failureThreshold, - syncHealth: vm.syncHealth, - metricFreshness: vm.metricFreshness, - }; -} - /** * The keys a bespoke card on this panel renders. Every OTHER configured * envelope entry falls through to `IntegrationFallbackRow` — the panel's unit @@ -320,21 +292,6 @@ export function ConnectionsPanel({ } }, [oauthOutcome, router, queryClient, t]); - const withingsViewModel = pickStatus(integrationStatus, "withings"); - const whoopViewModel = pickStatus(integrationStatus, "whoop"); - const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); - // v1.27.0 — Google Health reads off the same consolidated envelope; the - // ledger integration key is the hyphenated `google-health`. - const googleHealthViewModel = pickStatus(integrationStatus, "google-health"); - // v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards - // no longer fire their own /api//status round-trip. - const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar")); - const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura")); - // v1.28.x — Strava reads off the same consolidated envelope. - const stravaViewModel = toOAuthStatus( - pickStatus(integrationStatus, "strava"), - ); - const nightscoutViewModel = pickStatus(integrationStatus, "nightscout"); const unclaimedIntegrations = (integrationStatus?.integrations ?? []) .filter( (entry) => @@ -351,26 +308,41 @@ export function ConnectionsPanel({ {/* Per-provider anchors so deep-links (onboarding "more sources" chips, `/settings/integrations#oura`) scroll straight to the right card. */}
    -
    -
    - + {t("settings.fitbitTag")} + + {t("settings.fitbitExperimentalBadge")} + + + } />
    @@ -378,22 +350,30 @@ export function ConnectionsPanel({
    -
    - +
    -
    - +
    {/* Every configured envelope entry no card above claims. Normally this renders nothing; on an account carrying a dropped-but-still-running diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx deleted file mode 100644 index 8d61cb3ad..000000000 --- a/src/components/settings/integrations/fitbit-card.tsx +++ /dev/null @@ -1,537 +0,0 @@ -"use client"; - -// v1.12.0 — Fitbit (Fitbit & Pixel) card. Mirrors the WHOOP card: a -// BYO-key credentials form first, then an OAuth connect, then the -// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status -// reads off the consolidated /api/integrations/status envelope (the per-card -// /api/fitbit/status round-trip is gone); the pill/error/parked state comes off -// the same view-model like WHOOP. - -import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowRight, - HeartPulse, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { TagChip } from "@/components/ui/tag-chip"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; -import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, -} from "@/lib/query-keys"; - -import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateFor, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; -import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, - IntegrationRedirectGuide, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { loadDocument } from "@/lib/navigation/load-document"; - -export function FitbitCard({ - viewModel, - callbackUrl, -}: { - viewModel: IntegrationStatusViewModel | undefined; - /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ - callbackUrl: IntegrationCallbackUrls["fitbit"]; -}) { - const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - const queryClient = useQueryClient(); - - const status = viewModel; - - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost("/api/fitbit/disconnect"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - // Clear a parked integration via the resume endpoint. The CTA is - // rendered inside the parked banner below; success invalidates both - // the per-card status (so the pill flips back to connected - // immediately) and the cross-integration envelope (so any other view - // picks up the change on its next focus). - const resume = useMutation({ - mutationFn: async () => { - return apiPost<{ resumed: boolean; wasParked: boolean }>( - "/api/integrations/fitbit/resume", - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - async function handleSync(fullSync = false) { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw("/api/fitbit/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - t("settings.fitbitSyncResult", { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t("settings.fitbitSyncFailed"), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.fitbitSyncFailed"), - }); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await apiFetchRaw("/api/fitbit/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.fitbitCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - // The server resolves the verdict; the card only projects it. `connected` - // rides into that resolution, so a disconnected provider still lands on the - // "Not connected" pill without a second local rule here. - const pillState = pillStateFor(viewModel); - const pillLastSyncAt = pillTimestampFor(viewModel); - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - viewModel?.lastError - ? viewModel.lastError - : null; - - return ( - - - {t("settings.fitbitTag")} - - {t("settings.fitbitExperimentalBadge")} - - - } - description={ - - } - status={ - - } - /> - -
    - {errorMessage && } - {/* Parked-integration resume CTA. Surfaces only when the row - state is `parked` (>24h of persistent failures). The button - POSTs to /api/integrations/fitbit/resume which calls - `resumeIntegrationFromPark`; on success the per-card status - invalidates and the pill flips back to connected without a - page refresh. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - {resume.isError && ( -

    - {t("settings.integrationPill.resumeError")} -

    - )} - - {status?.connected && ( - - )} - {resume.isSuccess && resume.data?.wasParked && ( -

    - {t("settings.integrationPill.resumeSuccess")} -

    - )} - -
    -

    - {t("settings.fitbitCredentials")} -

    - {!status?.configured && ( - - )} -
    -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.fitbitCredentialsSavedPlaceholder") - : t("settings.fitbitClientId") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t("settings.fitbitCredentialsSavedPlaceholderSecret") - : t("settings.fitbitClientSecret") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - - {status?.connected ? ( - <> -
    - - - - - - - - - {t("settings.fitbitFullSyncTitle")} - - - {t("settings.fitbitFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.fitbitSynchronize")} - - - - - - - - - - - - - {t("settings.fitbitDisconnectTitle")} - - - {t("settings.fitbitDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.fitbitDisconnect")} - - - - -
    - {status?.backfillCompleted === false && ( -

    - {t("settings.fitbitBackfillInProgress")} -

    - )} - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.fitbitViewData")} - - - - ) : status?.configured ? ( - - - - ) : ( -

    - {t("settings.fitbitNoCredentials")} -

    - )} -
    - -
    - ); -} diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index 336848694..273a33300 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -1,72 +1,32 @@ "use client"; -// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air) card. Reads -// Fitbit and Pixel Watch data through the successor Google Health API — a -// separate, coexisting integration from the classic `fitbit` transport, which -// sunsets Sept 2026. Mirrors the Fitbit card anatomy: a BYO Google-Cloud -// client-id/secret form first, then an OAuth connect, then the -// sync/test/disconnect action row + parked-resume banner. Status reads off the -// consolidated /api/integrations/status envelope (no per-card round-trip). +// Google Health (Fitbit + Pixel Watch + Fitbit Air), read through the +// successor Google Health API: a separate, coexisting integration from the +// classic `fitbit` transport, which sunsets in September 2026. // -// One thing this card carries that the classic Fitbit card does not: a distinct -// RE-CONSENT CTA. Google expires the refresh token after 7 days while the -// operator's OAuth client stays in "Testing" publishing mode (the CASA-free -// path), so a connected user is periodically pushed back through OAuth. When the -// envelope reports `needsReauth`, the card surfaces a clear "Reconnect" banner -// separate from the connect/disconnect state. +// It renders the shared OAuth integration card. What it adds is its own sync +// choreography: a manual sync may outlive its request, so the card polls the +// authenticated status route until the run settles, shows the run's progress +// while it does, and lists the resources that did not complete in the details. +// Google's 7-day refresh expiry in "Testing" publishing mode is no longer a +// banner of its own: the server reports it as `action_required` / +// `reauth_required`, and the card's one primary button reconnects. import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowRight, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, - Watch, -} from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { TagChip } from "@/components/ui/tag-chip"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; +import { Badge } from "@/components/ui/badge"; +import { TagChip } from "@/components/ui/tag-chip"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; import { invalidateKeys, measurementDependentKeys, queryKeys, } from "@/lib/query-keys"; -import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateFor, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; import { completedGoogleHealthResourceCount, failedGoogleHealthResources, @@ -74,18 +34,17 @@ import { readGoogleHealthProgress, type GoogleHealthProgress, } from "./google-health-progress-view"; +import { + OAUTH_INTEGRATIONS, + OAuthIntegrationCardView, +} from "./oauth-integration-card"; +import type { IntegrationStatusViewModel } from "./shared"; import { readSyncOutcome, useSyncOutcomeMessage, type SyncOutcomeResource, type SyncOutcomeState, } from "./sync-outcome"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { loadDocument } from "@/lib/navigation/load-document"; export function GoogleHealthCard({ viewModel, @@ -102,17 +61,8 @@ export function GoogleHealthCard({ const [syncProgress, setSyncProgress] = useState( null, ); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); const queryClient = useQueryClient(); - const status = viewModel; - function invalidateCommittedWorkout(resources: SyncOutcomeResource[]) { const workoutCommitted = resources.some( (resource) => @@ -216,36 +166,6 @@ export function GoogleHealthCard({ } } - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost("/api/google-health/disconnect"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - // Clear a parked integration via the resume endpoint. The CTA is rendered - // inside the parked banner below; success invalidates both the per-card - // status and the cross-integration envelope so any other view picks up the - // change on its next focus. - const resume = useMutation({ - mutationFn: async () => { - return apiPost<{ resumed: boolean; wasParked: boolean }>( - "/api/integrations/google-health/resume", - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - async function handleSync(fullSync = false) { setSyncing(true); setSyncResult(null); @@ -335,360 +255,48 @@ export function GoogleHealthCard({ } } - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await apiFetchRaw("/api/google-health/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.googleHealthCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - // The server resolves the verdict; the card only projects it. `connected` - // rides into that resolution, so a disconnected provider still lands on the - // "Not connected" pill without a second local rule here. - const pillState = pillStateFor(viewModel); - const pillLastSyncAt = pillTimestampFor(viewModel); - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - viewModel?.lastError - ? viewModel.lastError - : null; - // The re-consent banner only makes sense while the connection still exists — - // a disconnected card has no token to renew. `needsReauth` is the 7-day - // Testing-mode expiry (or a revoked grant) surfaced by the status route. - const showReauth = Boolean(status?.connected && status?.needsReauth); const resourceFailures = failedGoogleHealthResources(syncProgress); const completedResources = completedGoogleHealthResourceCount(syncProgress); + const inProgress = syncProgress?.state === "in_progress"; return ( - - - {t("settings.googleHealthTag")} - - {t("settings.googleHealthBetaBadge")} - - - } - description={ - - } - status={ - - } - /> - -
    - {errorMessage && } - - {/* Re-consent CTA — distinct from parked/disconnected. Google expires - the refresh token after 7 days in "Testing" publishing mode (the - CASA-free path), so a connected user is periodically pushed back - through OAuth. Re-running connect mints a fresh grant. */} - {showReauth && ( -
    + {t("settings.googleHealthTag")} + - - {t("settings.googleHealthReauthBanner")} - - -
    - )} - - {/* Parked-integration resume CTA — surfaces only when the row state is - `parked` (>24h of persistent failures). */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - {resume.isError && ( -

    - {t("settings.integrationPill.resumeError")} -

    - )} - - {status?.connected && ( - - )} - {resume.isSuccess && resume.data?.wasParked && ( -

    - {t("settings.integrationPill.resumeSuccess")} -

    - )} - -
    -

    - {t("settings.googleHealthCredentials")} -

    -
    -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.googleHealthCredentialsSavedPlaceholder") - : t("settings.googleHealthClientId") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t( - "settings.googleHealthCredentialsSavedPlaceholderSecret", - ) - : t("settings.googleHealthClientSecret") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - - {status?.connected ? ( - <> -
    - - - - - - - - - {t("settings.googleHealthFullSyncTitle")} - - - {t("settings.googleHealthFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.googleHealthSynchronize")} - - - - - - - - - - - - - {t("settings.googleHealthDisconnectTitle")} - - - {t("settings.googleHealthDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.googleHealthDisconnect")} - - - - -
    - {status?.backfillCompleted === false && ( -

    - {t("settings.googleHealthBackfillInProgress")} -

    - )} - {syncProgress?.state === "in_progress" && ( + {t("settings.googleHealthBetaBadge")} + + + } + sync={{ + syncing, + result: syncResult, + run: (fullSync) => { + if (!fullSync && syncProgress?.state === "in_progress") { + void refreshSyncStatus(); + } else { + void handleSync(fullSync); + } + }, + }} + syncLabel={ + inProgress + ? t("settings.googleHealthSyncCheckStatus") + : t("settings.googleHealthSync") + } + fullSyncDisabled={inProgress} + syncResults={ + inProgress || syncResult ? ( +
    + {inProgress ? (

    - )} - {syncResult && ( + ) : null} + {syncResult ? ( - )} - {resourceFailures.length > 0 && ( -

    -

    - {t("settings.googleHealthResourceFailuresTitle")} -

    -
      - {resourceFailures.map((resource) => ( -
    • - - {resourceLabel(resource.resource)} - - {": "} - {resourceFailureMessage(resource)} -
    • - ))} -
    -
    - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.googleHealthViewData")} - - - - ) : status?.configured ? ( - - - - ) : ( -

    + ) : null + } + details={ + resourceFailures.length > 0 ? ( +

    - {t("settings.googleHealthNoCredentials")} -

    - )} -
    - - +

    + {t("settings.googleHealthResourceFailuresTitle")} +

    +
      + {resourceFailures.map((resource) => ( +
    • + + {resourceLabel(resource.resource)} + + {": "} + {resourceFailureMessage(resource)} +
    • + ))} +
    +
    + ) : undefined + } + /> ); } diff --git a/src/components/settings/integrations/integration-fallback-row.tsx b/src/components/settings/integrations/integration-fallback-row.tsx index c18f6c55a..10cbc002d 100644 --- a/src/components/settings/integrations/integration-fallback-row.tsx +++ b/src/components/settings/integrations/integration-fallback-row.tsx @@ -20,21 +20,10 @@ import { Plug } from "lucide-react"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { - formatRelative, - IntegrationStatusPill, -} from "@/components/settings/integration-status-pill"; import { useTranslations } from "@/lib/i18n/context"; -import { - pillFailurePropsFor, - pillStateFor, - pillTimestampFor, - type IntegrationKey, - type IntegrationStatusViewModel, -} from "./shared"; +import { IntegrationCard } from "./integration-card"; +import type { IntegrationKey, IntegrationStatusViewModel } from "./shared"; /** * Display names, mirroring the server-side `INTEGRATION_LABELS`. Only the @@ -52,50 +41,28 @@ export const INTEGRATION_DISPLAY_NAMES: Record = { strava: "Strava", }; +/** + * The same card every integration renders, read-only: the server's health, + * its sentence and its details, and no menu, because a dropped integration + * does not get its management surface back. + */ export function IntegrationFallbackRow({ status, - now, }: { status: IntegrationStatusViewModel; - /** Override "now" for deterministic testing. */ - now?: Date; }) { const { t } = useTranslations(); - - const pillState = pillStateFor(status); - const reference = now ?? new Date(); - const lastAttemptAt = - status.lastAttemptAt ?? status.syncHealth?.since ?? null; - const meta = lastAttemptAt - ? t("settings.integrationFallback.lastAttempt", { - relative: formatRelative( - Math.max(0, reference.getTime() - new Date(lastAttemptAt).getTime()), - t, - ), - }) - : t("settings.integrationFallback.noAttempt"); - + const name = + INTEGRATION_DISPLAY_NAMES[status.integration] ?? status.integration; return ( - - - } - /> -

    {meta}

    -
    + ); } diff --git a/src/components/settings/integrations/metric-freshness-disclosure.tsx b/src/components/settings/integrations/metric-freshness-disclosure.tsx deleted file mode 100644 index 54a23864e..000000000 --- a/src/components/settings/integrations/metric-freshness-disclosure.tsx +++ /dev/null @@ -1,184 +0,0 @@ -"use client"; - -/** - * Per-metric freshness, rendered. - * - * The server has computed a last-seen timestamp per `(source, type)` on every - * status call since v1.27.30 and nothing rendered it. That is the one signal - * that separates "the connection is broken" from "one pipe inside a healthy - * connection is dead" — a revoked HealthKit permission, a per-collection 403, - * an upstream that quietly stopped returning one data type. The integration - * pill cannot express it, because the integration itself is fine. - * - * Collapsed by default: a Withings or Google Health user carries 15-25 types - * and an always-open list would bury the card's actions. The collapsed summary - * still names how many types have gone quiet, so the signal never hides behind - * a click. - * - * Honest absence is structural: the server only reports `(source, type)` pairs - * that have rows, so a type a provider never delivered simply is not here. No - * "expected but missing" row is invented. - */ - -import { useState, type ReactNode } from "react"; -import { AlertTriangle, ChevronDown } from "lucide-react"; - -import { formatRelative } from "@/components/settings/integration-status-pill"; -import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; -import { useTranslations } from "@/lib/i18n/context"; -import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict"; -import { cn } from "@/lib/utils"; - -/** The pseudo-type carrying a source's newest workout. */ -const WORKOUT_TYPE = "WORKOUTS"; - -/** - * Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS` - * is a plain record, not enum-complete, so a newly-added type must degrade to - * "Walking asymmetry" rather than leaking `WALKING_ASYMMETRY` at the user. - */ -function humanise(type: string): string { - const words = type.toLowerCase().split("_").join(" "); - return words.charAt(0).toUpperCase() + words.slice(1); -} - -export function MetricFreshnessDisclosure< - Entry extends MetricFreshnessEntry = MetricFreshnessEntry, ->({ - entries, - idPrefix, - now, - title, - detail, -}: { - 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); - - if (!entries?.length) return null; - - const reference = now ?? new Date(); - const label = (type: string): string => { - if (type === WORKOUT_TYPE) - return t("settings.integrationFreshness.workouts"); - const key = MEASUREMENT_TYPE_LABEL_KEYS[type]; - return key ? t(key) : humanise(type); - }; - const relative = (lastSeenAt: string): string => - formatRelative( - Math.max(0, reference.getTime() - new Date(lastSeenAt).getTime()), - t, - ); - - // Dead pipes surface without scrolling; everything else stays alphabetical. - const sorted = [...entries].sort( - (a, b) => - Number(b.stale) - Number(a.stale) || - label(a.type).localeCompare(label(b.type)), - ); - const staleCount = entries.filter((entry) => entry.stale).length; - const newest = entries.reduce((max, entry) => - entry.lastSeenAt > max.lastSeenAt ? entry : max, - ); - const panelId = `${idPrefix}-metric-freshness-panel`; - - return ( -
    - - - {open && ( -
      - {sorted.map((entry) => { - const extra = detail?.(entry); - return ( -
    • -
      - - {entry.stale ? ( - - -
      - {extra ? ( -
      - {extra} -
      - ) : null} -
    • - ); - })} -
    - )} -
    - ); -} diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx index 5e082e322..df7673edd 100644 --- a/src/components/settings/integrations/nightscout-card.tsx +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -1,50 +1,36 @@ "use client"; -// v1.17.0 — Nightscout CGM integration card. Unlike WHOOP / Fitbit (OAuth, -// BYO-key), Nightscout is a URL + token the self-hoster pastes once: the user -// runs their own instance and HealthLog pulls continuous glucose off it. The -// card reads the consolidated `/api/integrations/status` envelope like every -// sibling — it was the last card firing its own status round-trip, and the -// last place a fourth status dialect could hide. Warm copy, mobile-first, the -// private-network access is operator-owned through an exact-origin server -// allowlist; ordinary users cannot weaken the outbound-request policy. +// Nightscout CGM. Unlike the OAuth providers, Nightscout is a URL and an +// optional token the self-hoster pastes once: they run their own instance and +// HealthLog pulls continuous glucose from it. The card reads the consolidated +// `/api/integrations/status` envelope like every sibling and renders the +// shared integration card. Private-network access is operator-owned through an +// exact-origin server allowlist; an ordinary user cannot weaken the +// outbound-request policy, and the card says so when that is what stands in +// the way. import { useRef, useState } from "react"; -import Link from "next/link"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, + BookOpen, Droplet, + KeyRound, Link2, Loader2, + Plug, RefreshCw, Save, Unlink, } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; import { TagChip } from "@/components/ui/tag-chip"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, @@ -52,20 +38,21 @@ import { queryKeys, } from "@/lib/query-keys"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; + ActionResultLines, + useConnectionTest, + useStandardSync, +} from "./integration-actions"; import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateForVerdict, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { IntegrationCardDescription } from "./setup-guide-link"; + IntegrationCard, + type IntegrationMenuAction, +} from "./integration-card"; +import { MetricFreshnessList } from "./metric-freshness-list"; +import { + IntegrationCardDescription, + integrationDocsHref, +} from "./setup-guide-link"; +import { failureStreakFor, type IntegrationStatusViewModel } from "./shared"; export function NightscoutCard({ viewModel, @@ -73,19 +60,17 @@ export function NightscoutCard({ viewModel?: IntegrationStatusViewModel; }) { const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); const queryClient = useQueryClient(); - - const [url, setUrl] = useState(""); - const [token, setToken] = useState(""); - const [saving, setSaving] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const status = viewModel; + const connected = status?.connected ?? false; + const sync = useStandardSync({ + provider: "nightscout", + i18nPrefix: "settings.nightscout", + acceptsFullSync: false, + }); + const test = useConnectionTest("/api/nightscout/test"); + const [editing, setEditing] = useState(false); const formRef = useRef(null); const disconnect = useMutation({ @@ -93,114 +78,50 @@ export function NightscoutCard({ await apiPost("/api/nightscout/disconnect"); }, onSuccess: () => { - setUrl(""); - setToken(""); // The card reads the consolidated envelope, so that is the one key a - // disconnect has to invalidate for the pill to flip back. + // disconnect has to invalidate for the card to flip back. queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); }, }); - async function handleConnect(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setMsg(null); - setMsgType(null); - try { - const res = await apiFetchRaw("/api/nightscout/connect", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: url.trim(), - token: token.trim(), - }), - }); - if (res.ok) { - setMsg(t("settings.nightscoutConnected")); - setMsgType("success"); - setToken(""); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - let detail = t("settings.nightscoutConnectFailed"); - try { - const json = await res.json(); - if (json.error) detail = json.error; - } catch { - // keep the generic message - } - setMsg(detail); - setMsgType("error"); - } - } catch { - setMsg(t("common.networkError")); - setMsgType("error"); - } - setSaving(false); + // A pause or a lapsed token is resolved by submitting the address again: + // there is no OAuth round-trip to re-run, so the button opens the form and + // puts the cursor in the URL field. + function openForm() { + setEditing(true); + requestAnimationFrame(() => { + formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + formRef.current + ?.querySelector("#nightscout-url") + ?.focus(); + }); } - // Sync now. Same shape as the WHOOP card's incremental arm, minus the - // full-history dialog — Nightscout has no full-sync arm to call. - async function handleSync() { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - t("settings.nightscoutSyncResult", { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t("settings.nightscoutSyncFailed"), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.nightscoutSyncFailed"), - }); - } finally { - setSyncing(false); - } - } - - const pillState = pillStateForVerdict(status?.syncHealth?.verdict); + // The newest recorded attempt names the one failure the person cannot fix + // from here: the instance sits on a private network the operator has not + // approved. Read from the health diagnostics' short error code, never from + // a stored message. const operatorApprovalRequired = - status?.lastError?.startsWith("private_origin_not_approved") ?? false; - // `warning` joins the set now that a transient failure paints amber rather - // than red — the detail belongs on this line, not in a colour that tells the - // user to reconnect against something reconnecting cannot fix. - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - status?.lastError && - !operatorApprovalRequired - ? status.lastError - : null; + status?.health?.diagnostics.recentCalls[0]?.errorCode === + "private_origin_not_approved"; - return ( - - + ); + + if (!connected) { + return ( + {t("settings.nightscoutTag")}} description={ } - status={ - - } + loading={viewModel === undefined} + connected={false} + health={null} + setup={form} /> + ); + } -
    - {errorMessage && } - {operatorApprovalRequired && ( -
    -

    - {t("settings.nightscoutPrivateOperatorTitle")} -

    -

    - {t("settings.nightscoutPrivateOperatorDescription")} -

    -
    - )} + const menu: IntegrationMenuAction[] = [ + ...(operatorApprovalRequired + ? [] + : [ + { + id: "sync", + label: t("settings.nightscoutSync"), + icon: RefreshCw, + disabled: sync.syncing, + onSelect: () => sync.run(false), + }, + { + id: "test", + label: t("settings.testConnection.test"), + icon: Plug, + disabled: test.testing, + onSelect: test.run, + }, + ]), + { + id: "view-data", + label: t("settings.nightscoutViewData"), + icon: ArrowRight, + href: "/insights/blood-glucose", + }, + { + id: "credentials", + label: t("settings.integrationCard.menu.address"), + icon: KeyRound, + onSelect: () => setEditing((open) => !open), + }, + { + id: "setup-guide", + label: t("settings.integrationSetupGuide"), + icon: BookOpen, + href: integrationDocsHref("nightscout"), + external: true, + }, + { + id: "disconnect", + label: t("settings.nightscoutDisconnect"), + icon: Unlink, + destructive: true, + onSelect: () => disconnect.mutate(), + confirm: { + title: t("settings.nightscoutDisconnectTitle"), + body: t("settings.nightscoutDisconnectDescription"), + confirmLabel: t("settings.nightscoutDisconnect"), + }, + }, + ]; - {/* Parked-integration resume CTA. Surfaces only when the row state - is `parked` (>24h of persistent failures). Nightscout has no OAuth - redirect — the user re-validates by re-submitting the connect form, - so "reconnect" scrolls the form into view + focuses the URL field. - Markup matches the WHOOP card byte for byte. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} + const streak = failureStreakFor(status); - {status?.connected && ( - - )} + return ( + {t("settings.nightscoutTag")}} + connected + health={status?.health ?? null} + primary={{ onReconnect: openForm, onResume: openForm }} + menu={menu} + diagnosticsExtra={ + streak + ? [ + t("settings.integrationCard.details.failureStreak", { + count: streak.count, + threshold: streak.threshold, + }), + ] + : undefined + } + details={ + status?.metricFreshness?.length ? ( + + ) : undefined + } + > + {operatorApprovalRequired ? ( + // The one fact the generic sentence cannot carry: the instance sits + // on a private network the operator has not approved, which the + // person cannot fix from here. Said in the body, not as a banner. +

    + {t("settings.nightscoutPrivateOperatorTitle")}{" "} + {t("settings.nightscoutPrivateOperatorDescription")} +

    + ) : null} + + {editing ? form : null} +
    + ); +} -
    -
    - - setUrl(e.target.value)} - placeholder={ - status?.configured - ? t("settings.nightscoutUrlSavedPlaceholder") - : "https://your-site.up.railway.app" - } - maxLength={2048} - autoComplete="off" - inputMode="url" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setToken(e.target.value)} - placeholder={ - status?.hasToken - ? t("settings.nightscoutTokenSavedPlaceholder") - : t("settings.nightscoutTokenOptional") - } - maxLength={512} - autoComplete="off" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -

    - {t("settings.nightscoutTokenHelp")} -

    -
    +function NightscoutForm({ + formRef, + connected, + configured, + hasToken, +}: { + formRef: React.RefObject; + connected: boolean; + configured: boolean; + hasToken: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [url, setUrl] = useState(""); + const [token, setToken] = useState(""); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ + kind: "success" | "error"; + text: string; + } | null>(null); - - - + async function handleConnect(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + try { + const res = await apiFetchRaw("/api/nightscout/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: url.trim(), token: token.trim() }), + }); + if (res.ok) { + setMsg({ kind: "success", text: t("settings.nightscoutConnected") }); + setToken(""); + void invalidateKeys(queryClient, measurementDependentKeys); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + let text = t("settings.nightscoutConnectFailed"); + try { + const json = await res.json(); + if (json.error) text = json.error; + } catch { + // keep the generic message + } + setMsg({ kind: "error", text }); + } + } catch { + setMsg({ kind: "error", text: t("common.networkError") }); + } + setSaving(false); + } - {msg && ( -

    - {msg} -

    + return ( + +
    + + setUrl(e.target.value)} + placeholder={ + configured + ? t("settings.nightscoutUrlSavedPlaceholder") + : "https://your-site.up.railway.app" + } + maxLength={2048} + autoComplete="off" + inputMode="url" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="next" + /> +
    +
    + + setToken(e.target.value)} + placeholder={ + hasToken + ? t("settings.nightscoutTokenSavedPlaceholder") + : t("settings.nightscoutTokenOptional") + } + maxLength={512} + autoComplete="off" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="done" + /> +

    + {t("settings.nightscoutTokenHelp")} +

    +
    + {msg && ( +

    + {msg.text} +

    + )} + + - - - )} - - - - - - - - {t("settings.nightscoutDisconnectTitle")} - - - {t("settings.nightscoutDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.nightscoutDisconnect")} - - - - -
    - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where the glucose - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.nightscoutViewData")} - - - - )} -
    -
    + {connected + ? t("settings.nightscoutUpdate") + : t("settings.nightscoutConnect")} + + + ); } diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx deleted file mode 100644 index 2811fd460..000000000 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ /dev/null @@ -1,551 +0,0 @@ -"use client"; - -// v1.17.0 (F4) — shared OAuth integration card for Polar / Oura. -// v1.17.1 — these are now per-user BYO-key integrations (like WHOOP / Fitbit): -// the card renders an optional "your OAuth app credentials" form (driven by the -// `credentials` prop) above the connect button. Credentials resolve DB-first -// then env on the server, so a user who pastes their own client id/secret uses -// their own app while existing env-configured deploys keep working unchanged. -// Mirrors the Nightscout card's self-contained status pattern. - -import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowRight, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, - type LucideIcon, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; -import { useTranslations } from "@/lib/i18n/context"; -import type { - MetricFreshnessEntry, - SyncHealth, -} from "@/lib/integrations/sync-verdict"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, -} from "@/lib/query-keys"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; -import { OuraScopeNote } from "./oura-scope-note"; -import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; -import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateForVerdict, - type IntegrationState, -} from "./shared"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, - IntegrationRedirectGuide, - type IntegrationDocsProvider, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import type { OuraScopeGatedCollection } from "@/lib/oura/scope"; -import { loadDocument } from "@/lib/navigation/load-document"; - -export interface OAuthProviderStatus { - connected: boolean; - configured: boolean; - available: boolean; - /** Whether the user has stored their own BYO client id/secret pair. */ - hasOwnCredentials?: boolean; - /** - * The raw ledger state, carried for shape parity with the envelope. The - * card paints from `syncHealth.verdict`, never from this — which is why - * v1.38.19's `unknown` member needs no arm here. - */ - state?: IntegrationState; - lastSuccessAt?: string | null; - lastAttemptAt?: string | null; - lastError?: string | null; - legacyLastSyncedAt?: string | null; - consecutiveFailuresByKind?: { - transient: number; - reauth_required: number; - persistent: number; - } | null; - failureThreshold?: number; - /** The server-resolved liveness verdict; the pill is a projection of it. */ - syncHealth?: SyncHealth; - metricFreshness?: MetricFreshnessEntry[]; - /** Oura: collections the stored grant does not cover. */ - scopeReconnect?: OuraScopeGatedCollection[]; -} - -export interface OAuthProviderCardProps { - /** Lower-case provider key, used for routes + query keys + testids. */ - provider: "polar" | "oura" | "strava"; - /** The provider's own query key, invalidated after connect / sync / save. */ - statusQueryKey: readonly unknown[]; - /** i18n key prefix (e.g. `settings.polar`). */ - i18nPrefix: string; - /** Distinct card glyph, kept in the same size/treatment as the other cards. */ - icon: LucideIcon; - /** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */ - dataHref: string; - /** When set, render a per-user BYO OAuth-credentials form above the connect - * button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */ - credentials?: boolean; - /** - * The card's status, sourced off the consolidated - * `/api/integrations/status` envelope. The card used to fall back to its own - * `/api//status` round-trip when this was absent; that fallback is - * gone. Every mount passes a view-model, the per-provider response carries no - * `syncHealth` so the fallback would have painted a wrong verdict, and during - * envelope load it fired three redundant requests for a caller that does not - * exist. - */ - viewModel?: OAuthProviderStatus; - /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ - callbackUrl: IntegrationCallbackUrls[OAuthProviderCardProps["provider"]]; -} - -export function OAuthProviderCard({ - provider, - statusQueryKey, - i18nPrefix, - icon, - dataHref, - credentials = false, - viewModel, - callbackUrl, -}: OAuthProviderCardProps) { - const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); - const queryClient = useQueryClient(); - const [msg, setMsg] = useState(null); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - - const status = viewModel; - - // Sync now. Same shape as the WHOOP card's incremental arm, minus the - // full-history dialog — none of these providers has a full-sync arm to call. - async function handleSync() { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw(`/api/${provider}/sync`, { - method: "POST", - }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - t(`${i18nPrefix}SyncResult`, { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ queryKey: statusQueryKey }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t(`${i18nPrefix}SyncFailed`), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t(`${i18nPrefix}SyncFailed`), - }); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - try { - const res = await apiFetchRaw(`/api/${provider}/credentials`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - if (res.ok) { - setCredsMsg(t(`${i18nPrefix}CredentialsSaved`)); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: statusQueryKey }); - // The card may read off the consolidated envelope — invalidate it too - // so the saved-credentials state repaints regardless of the source. - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost(`/api/${provider}/disconnect`); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: statusQueryKey }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - const pillState = pillStateForVerdict(status?.syncHealth?.verdict); - // The pill says what; this line says why. `warning` joins the set now that a - // transient failure no longer paints red — the detail moved here from a - // colour that told the user to reconnect against something reconnecting - // cannot fix. - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - status?.lastError - ? status.lastError - : null; - const serverUnavailable = status && !status.available; - - function handleConnect() { - setMsg(null); - void invalidateKeys(queryClient, measurementDependentKeys); - loadDocument(`/api/${provider}/connect`); - } - - return ( - - - } - status={ - - } - /> - -
    - {errorMessage && ( -
    - -
    - )} - - {/* Parked-integration resume CTA. Surfaces only when the row state - is `parked` (>24h of persistent failures). For an env-based OAuth - provider there are no stored BYO credentials to re-validate — the - grant itself has to be re-issued, so "reconnect" re-initiates the - existing connect flow (`/api//connect`); a successful - callback clears the park. Markup matches the WHOOP card byte for - byte. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - - {provider === "oura" && status?.connected && ( - - )} - - {status?.connected && ( - - )} - - {credentials && ( -
    -

    - {t(`${i18nPrefix}Credentials`)} -

    - {!status?.hasOwnCredentials && ( - - )} -
    -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.hasOwnCredentials - ? t(`${i18nPrefix}CredentialsSavedPlaceholder`) - : t(`${i18nPrefix}ClientId`) - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.hasOwnCredentials - ? t(`${i18nPrefix}CredentialsSavedPlaceholder`) - : t(`${i18nPrefix}ClientSecret`) - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - )} - - {serverUnavailable && ( -

    - {t(`${i18nPrefix}Unavailable`)} -

    - )} - - {status?.connected ? ( - <> -
    - - - - - - - - - - {t(`${i18nPrefix}DisconnectTitle`)} - - - {t(`${i18nPrefix}DisconnectDescription`)} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t(`${i18nPrefix}Disconnect`)} - - - - -
    - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t(`${i18nPrefix}ViewData`)} - - - - ) : ( - - - - )} - - {msg && ( -

    - {msg} -

    - )} -
    - -
    - ); -} diff --git a/src/components/settings/integrations/oura-card.tsx b/src/components/settings/integrations/oura-card.tsx deleted file mode 100644 index cb669d917..000000000 --- a/src/components/settings/integrations/oura-card.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -// v1.17.0 (F4) — Oura Cloud integration card. Thin wrapper over the shared -// OAuth card. -// v1.17.1 — per-user BYO OAuth credentials (DB-first then env). - -import { CircleDot } from "lucide-react"; - -import { - OAuthProviderCard, - type OAuthProviderStatus, -} from "@/components/settings/integrations/oauth-provider-card"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { queryKeys } from "@/lib/query-keys"; - -export function OuraCard({ - viewModel, - callbackUrl, -}: { - viewModel?: OAuthProviderStatus; - callbackUrl: IntegrationCallbackUrls["oura"]; -}) { - return ( - - ); -} diff --git a/src/components/settings/integrations/oura-scope-note.tsx b/src/components/settings/integrations/oura-scope-note.tsx deleted file mode 100644 index 0ed0b75ca..000000000 --- a/src/components/settings/integrations/oura-scope-note.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { Link2 } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/lib/i18n/context"; -import type { OuraScopeGatedCollection } from "@/lib/oura/scope"; - -/** - * A calm note on the Oura card when the stored grant does not cover some - * collections (they answer 401 while their siblings succeed). Not an error and - * not counted toward the reconnect threshold: everything else keeps syncing, - * and one reconnect asks Oura for the missing scopes. - */ -export function OuraScopeNote({ - collections, - onReconnect, - disabled, -}: { - collections: readonly OuraScopeGatedCollection[] | undefined; - onReconnect: () => void; - disabled?: boolean; -}) { - const { t, locale } = useTranslations(); - if (!collections || collections.length === 0) return null; - - const labels: Record = { - spo2: t("settings.integrations.oura.scope.items.spo2"), - vo2max: t("settings.integrations.oura.scope.items.vo2max"), - cardio_age: t("settings.integrations.oura.scope.items.cardio_age"), - resilience: t("settings.integrations.oura.scope.items.resilience"), - }; - const list = new Intl.ListFormat(locale, { - style: "long", - type: "conjunction", - }).format(collections.map((c) => labels[c])); - - return ( -
    -

    - {t("settings.integrations.oura.scope.body", { list })} -

    - -
    - ); -} diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx deleted file mode 100644 index 4900e449e..000000000 --- a/src/components/settings/integrations/polar-card.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the -// shared OAuth card. -// v1.17.1 — per-user BYO OAuth credentials (DB-first then env). - -import { Watch } from "lucide-react"; - -import { - OAuthProviderCard, - type OAuthProviderStatus, -} from "@/components/settings/integrations/oauth-provider-card"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { queryKeys } from "@/lib/query-keys"; - -export function PolarCard({ - viewModel, - callbackUrl, -}: { - viewModel?: OAuthProviderStatus; - callbackUrl: IntegrationCallbackUrls["polar"]; -}) { - return ( - - ); -} diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index 396a7b97d..56999a5b9 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -8,23 +8,12 @@ */ import { useQuery } from "@tanstack/react-query"; -import { AlertCircle } from "lucide-react"; -import type { IntegrationPillState } from "@/components/settings/integration-status-pill"; import { apiGet } from "@/lib/api/api-fetch"; -import type { - MetricFreshnessEntry, - SyncHealth, - SyncVerdict, -} from "@/lib/integrations/sync-verdict"; +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; +import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict"; import { queryKeys } from "@/lib/query-keys"; -import type { WithingsPlanFeature } from "@/lib/withings/plan-features"; -import type { OuraScopeGatedCollection } from "@/lib/oura/scope"; -// v1.4.15 Phase B2: shared status payload for both integration cards. -// v1.4.19 Phase A5: the redundant in-card status banner is gone — the -// IntegrationStatusPill now owns state + last-sync presentation, and -// the actionable error message is shown inline above the action row. export type IntegrationKey = | "withings" | "whoop" @@ -38,23 +27,20 @@ export type IntegrationKey = // Nightscout folds onto the envelope so the card stops fetching its own // status and inherits the shared verdict. | "nightscout"; -/** Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. */ -export type IntegrationState = - | "connected" - | "error_transient" - | "error_reauth" - | "disconnected" - | "parked" - // v1.38.19 — no ledger row: the ledger has no history for this provider. - // Read like `disconnected`; `syncHealth.verdict` owns liveness. - | "unknown"; - +/** + * One envelope entry as the cards read it. Since v1.42.4 the cards render + * `health` alone; the older health-shaped fields (ledger `state`, `lastError`, + * `syncHealth`, token expiry, plan and scope lists, `needsReauth`) stay on the + * wire for one transition release and are deliberately not declared here, so + * the compiler refuses a card that starts reading them again. + */ export interface IntegrationStatusViewModel { integration: IntegrationKey; - state: IntegrationState; - lastSuccessAt: string | null; - lastAttemptAt: string | null; - lastError: string | null; + /** + * The one server-resolved state (ok / limited / degraded / action_required), + * null while the provider is not connected. + */ + health?: ConnectionHealth | null; consecutiveFailuresByKind?: { transient: number; reauth_required: number; @@ -64,38 +50,17 @@ export interface IntegrationStatusViewModel { failureThreshold?: number; configured?: boolean; connected?: boolean; - connectedAt?: string | null; - legacyLastSyncedAt?: string | null; - tokenExpiresAt?: string | null; - tokenExpired?: boolean | null; - enabled?: boolean; - // Withings activity-scope reconnect banner. - scope?: string | null; - hasActivityScope?: boolean; - // Withings: readings the account's API plan withholds (server-resolved). - planUnavailable?: WithingsPlanFeature[]; - // Oura: collections the stored grant does not cover (server-resolved). - scopeReconnect?: OuraScopeGatedCollection[]; - // WHOOP / Fitbit backfill-in-progress note. + /** WHOOP / Fitbit / Google Health: older history still loading. */ backfillCompleted?: boolean | null; - // v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has - // lapsed (the 7-day "Testing"-mode expiry, or a revoked grant). The card - // paints a distinct re-consent CTA off this flag, separate from the parked - // state. Server-populated on the `/api/integrations/status` envelope. - needsReauth?: boolean; - // Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available` - // greys out the connect button when no credentials resolve; `hasOwnCredentials` - // drives the saved-placeholder UI. - available?: boolean; - hasOwnCredentials?: boolean; - // Nightscout: a stored token, and the private-network opt-in. - hasToken?: boolean; - allowPrivateHost?: boolean; /** - * The server-resolved liveness verdict. Present on every envelope entry; - * `undefined` only while the envelope is still loading. + * Polar / Oura / Strava: usable credentials resolve (BYO or the shared + * app). Greys out "Connect" when false. */ - syncHealth?: SyncHealth; + available?: boolean; + /** Polar / Oura / Strava: the person stored their own client id/secret. */ + hasOwnCredentials?: boolean; + /** Nightscout: a stored token. */ + hasToken?: boolean; /** Per-metric last-seen timestamps with the server-computed stale flag. */ metricFreshness?: MetricFreshnessEntry[]; } @@ -106,10 +71,8 @@ export interface IntegrationStatusEnvelope { } /** - * Shared status fetch for the Settings → Integrations card. Returns - * the per-integration view-model AND the global threshold so the - * "{n}/{threshold} consecutive failures" string in the UI is single- - * sourced from the server. + * Shared status fetch for the Settings → Integrations cards. Returns the + * per-integration view-model and the global failure threshold. */ export function useIntegrationStatuses(enabled: boolean) { return useQuery({ @@ -135,116 +98,26 @@ export function pickStatus( } /** - * The one verdict→pill mapping in the tree. - * - * There used to be three of these, one per card family, and they disagreed: - * the same ledger state painted red on one card and orange on the next, on the - * same page. The server now resolves the verdict once and this projects it once - * — a fourth dialect cannot be minted without the structural guard noticing. - * - * `failing` deliberately lands on the warning tone, not the destructive one: - * **red is reserved for "your action fixes this"** (reconnect, resume). Telling - * a user to reconnect against a 503 sends them clicking at something they - * cannot repair; the streak detail survives in the inline error line and the - * "{n}/{threshold} consecutive failures" string the envelope single-sources. + * The current failure streak paired with the server-owned alert threshold, for + * the card's copied diagnostics. The card itself shows no counter: whether a + * streak matters is already in `health`. The alert ladder uses the same + * maximum, so the ratio cannot disagree with when a notification goes out. */ -export function pillStateForVerdict( - verdict: SyncVerdict | undefined, -): IntegrationPillState { - switch (verdict) { - case "fresh": - return "connected"; - case "stale": - return "stale"; - case "stalled": - return "stalled"; - case "failing": - return "warning"; - case "reauth_required": - return "error"; - case "parked": - return "parked"; - case "pending_first_sync": - return "pending-setup"; - case "disconnected": - case undefined: - return "disconnected"; - } -} - -/** Project a view-model onto its pill state. Undefined = still loading. */ -export function pillStateFor( - status: IntegrationStatusViewModel | undefined, -): IntegrationPillState { - return pillStateForVerdict(status?.syncHealth?.verdict); -} - -export interface IntegrationPillFailureProps { - failureCount?: number; - failureThreshold?: number; -} - -/** - * Pair the largest per-kind streak with the server-owned alert threshold. The - * alert ladder uses the same maximum, so the ratio cannot disagree with when a - * notification becomes eligible. - */ -export function pillFailurePropsFor( +export function failureStreakFor( status: | Pick< IntegrationStatusViewModel, "consecutiveFailuresByKind" | "failureThreshold" > | undefined, -): IntegrationPillFailureProps { +): { count: number; threshold: number } | null { const buckets = status?.consecutiveFailuresByKind; - const failureThreshold = status?.failureThreshold; - if (!buckets || failureThreshold === undefined) return {}; - - const failureCount = Math.max( + const threshold = status?.failureThreshold; + if (!buckets || threshold === undefined) return null; + const count = Math.max( buckets.transient, buckets.reauth_required, buckets.persistent, ); - return failureCount > 0 ? { failureCount, failureThreshold } : {}; -} - -/** - * The timestamp the pill shows inline. `connected` reads the last success (a - * connection row's own `lastSyncedAt` wins when it is newer, which is what - * repaints a fresh "just now" straight after a manual sync); the aging and - * failing states read the instant that triggered the verdict. - * - * `failing` must NOT fall through to the `legacyLastSyncedAt` arm below. That - * field is a connection row's own clock, kept fresh by the very sync that is - * erroring, so a provider that had delivered nothing for a fortnight painted a - * "just now" beside its warning. - */ -export function pillTimestampFor( - status: IntegrationStatusViewModel | undefined, -): string | null { - const verdict = status?.syncHealth?.verdict; - if (verdict === "stale" || verdict === "stalled" || verdict === "failing") { - return status?.syncHealth?.since ?? null; - } - return status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null; -} - -/** - * Inline actionable error message that surfaces under the pill when a - * sync attempt failed. The pill conveys "something is wrong"; this - * line tells the user *what* is wrong so they can act on it. Keeping - * it deliberately small (one icon + one line) so it doesn't recreate - * the v1.4.18 redundant banner the maintainer removed. - */ -export function IntegrationErrorMessage({ message }: { message: string }) { - return ( -

    - - {message} -

    - ); + return count > 0 ? { count, threshold } : null; } diff --git a/src/components/settings/integrations/strava-card.tsx b/src/components/settings/integrations/strava-card.tsx deleted file mode 100644 index 534558e02..000000000 --- a/src/components/settings/integrations/strava-card.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -// v1.28.x — Strava integration card. Thin wrapper over the shared OAuth card -// (per-user BYO OAuth credentials, DB-first then env). Strava is a workout -// source: connecting it syncs your activities into the workout surface, where -// the cross-source picker collapses any twin already captured by a wearable. - -import { Activity } from "lucide-react"; - -import { - OAuthProviderCard, - type OAuthProviderStatus, -} from "@/components/settings/integrations/oauth-provider-card"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { queryKeys } from "@/lib/query-keys"; - -export function StravaCard({ - viewModel, - callbackUrl, -}: { - viewModel?: OAuthProviderStatus; - callbackUrl: IntegrationCallbackUrls["strava"]; -}) { - return ( - - ); -} diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx deleted file mode 100644 index ad4c04d8d..000000000 --- a/src/components/settings/integrations/whoop-card.tsx +++ /dev/null @@ -1,519 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - Activity, - ArrowRight, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; -import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, -} from "@/lib/query-keys"; - -import { - IntegrationErrorMessage, - pillFailurePropsFor, - pillStateFor, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; -import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, - IntegrationRedirectGuide, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { loadDocument } from "@/lib/navigation/load-document"; - -export function WhoopCard({ - viewModel, - callbackUrl, -}: { - viewModel: IntegrationStatusViewModel | undefined; - /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ - callbackUrl: IntegrationCallbackUrls["whoop"]; -}) { - const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - const queryClient = useQueryClient(); - - // v1.12.1 — read off the consolidated /api/integrations/status - // envelope; the per-card /api/whoop/status round-trip is gone. - const status = viewModel; - - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost("/api/whoop/disconnect"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - // Clear a parked integration via the resume endpoint. The CTA is - // rendered inside the parked banner below; success invalidates both - // the per-card status (so the pill flips back to connected - // immediately) and the cross-integration envelope (so any other view - // picks up the change on its next focus). - const resume = useMutation({ - mutationFn: async () => { - return apiPost<{ resumed: boolean; wasParked: boolean }>( - "/api/integrations/whoop/resume", - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - async function handleSync(fullSync = false) { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw("/api/whoop/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - t("settings.whoopSyncResult", { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t("settings.whoopSyncFailed"), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.whoopSyncFailed"), - }); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await apiFetchRaw("/api/whoop/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.whoopCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - // The server resolves the verdict; the card only projects it. `connected` - // rides into that resolution, so a disconnected provider still lands on the - // "Not connected" pill without a second local rule here. - const pillState = pillStateFor(viewModel); - const pillLastSyncAt = pillTimestampFor(viewModel); - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - viewModel?.lastError - ? viewModel.lastError - : null; - - return ( - - - } - status={ - - } - /> - -
    - {errorMessage && } - {/* Parked-integration resume CTA. Surfaces only when the row - state is `parked` (>24h of persistent failures). The button - POSTs to /api/integrations/whoop/resume which calls - `resumeIntegrationFromPark`; on success the per-card status - invalidates and the pill flips back to connected without a - page refresh. Wider tap target than the inline action row so - it stays reachable on a Pixel 5 viewport. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - {resume.isError && ( -

    - {t("settings.integrationPill.resumeError")} -

    - )} - - {status?.connected && ( - - )} - {resume.isSuccess && resume.data?.wasParked && ( -

    - {t("settings.integrationPill.resumeSuccess")} -

    - )} - -
    -

    - {t("settings.whoopCredentials")} -

    - {!status?.configured && ( - - )} -
    -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.whoopCredentialsSavedPlaceholder") - : t("settings.whoopClientId") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t("settings.whoopCredentialsSavedPlaceholderSecret") - : t("settings.whoopClientSecret") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - - {status?.connected ? ( - <> -
    - - - - - - - - - {t("settings.whoopFullSyncTitle")} - - - {t("settings.whoopFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.whoopSynchronize")} - - - - - - - - - - - - - {t("settings.whoopDisconnectTitle")} - - - {t("settings.whoopDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.whoopDisconnect")} - - - - -
    - {status?.backfillCompleted === false && ( -

    - {t("settings.whoopBackfillInProgress")} -

    - )} - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.whoopViewData")} - - - - ) : status?.configured ? ( - - - - ) : ( -

    - {t("settings.whoopNoCredentials")} -

    - )} -
    - -
    - ); -} diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx deleted file mode 100644 index b58e7f9da..000000000 --- a/src/components/settings/integrations/withings-card.tsx +++ /dev/null @@ -1,573 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowRight, - Link2, - Loader2, - RefreshCw, - Save, - Unlink, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/ui/password-input"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { TestConnectionButton } from "@/components/settings/test-connection-button"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; -import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; -import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - measurementDependentKeys, - queryKeys, -} from "@/lib/query-keys"; - -import { - IntegrationErrorMessage, - pillStateFor, - pillFailurePropsFor, - pillTimestampFor, - type IntegrationStatusViewModel, -} from "./shared"; -import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; -import { WithingsPlanNote } from "./withings-plan-note"; -import { - readSyncOutcome, - useSyncOutcomeMessage, - type SyncOutcomeState, -} from "./sync-outcome"; -import { - CallbackMismatchNotice, - IntegrationCardDescription, - IntegrationRedirectGuide, -} from "./setup-guide-link"; -import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; -import { loadDocument } from "@/lib/navigation/load-document"; - -export function WithingsCard({ - viewModel, - callbackUrl, -}: { - viewModel: IntegrationStatusViewModel | undefined; - /** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */ - callbackUrl: IntegrationCallbackUrls["withings"]; -}) { - const { t } = useTranslations(); - const describeSyncOutcome = useSyncOutcomeMessage(); - const [syncing, setSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - const queryClient = useQueryClient(); - - // v1.12.1 — the Withings status is read off the consolidated - // /api/integrations/status envelope (the `viewModel` prop). The - // per-card /api/withings/status round-trip is gone; the envelope - // carries every field this card renders (connected / configured / - // last-sync / activity scope). - const status = viewModel; - - const disconnect = useMutation({ - mutationFn: async () => { - await apiPost("/api/withings/disconnect"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - // v1.4.43 W14 — clear a parked integration via the resume endpoint. - // The CTA is rendered inside the parked banner below; success - // invalidates both the per-card status (so the pill flips back to - // connected immediately) and the cross-integration envelope (so any - // other view picks up the change on its next focus). - const resume = useMutation({ - mutationFn: async () => { - return apiPost<{ resumed: boolean; wasParked: boolean }>( - "/api/integrations/withings/resume", - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - }, - }); - - async function handleSync(fullSync = false) { - setSyncing(true); - setSyncResult(null); - try { - const res = await apiFetchRaw("/api/withings/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - const json = await res.json(); - const result = res.ok ? readSyncOutcome(json) : null; - if (result) { - // The tone comes off what the run wrote, not off `res.ok`. - setSyncResult({ - outcome: result.outcome, - message: describeSyncOutcome( - result, - fullSync - ? t("settings.withingsFullSyncResult", { count: result.imported }) - : t("settings.withingsSyncResult", { count: result.imported }), - ), - }); - void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - setSyncResult({ - outcome: "failed", - message: json?.error || t("settings.withingsSyncFailed"), - }); - } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.withingsSyncFailed"), - }); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await apiFetchRaw("/api/withings/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.withingsCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - // The pill state derives from the cross-integration status - // envelope. `legacyLastSyncedAt` is the connection row's own - // last-sync timestamp (carried in the same envelope) so the pill - // still paints a fresh "X min ago" right after a manual sync once - // the envelope refetches. When the envelope hasn't answered yet we - // fall back to "disconnected" so the card never renders status-less. - // The server resolves the verdict; the card only projects it. `connected` - // rides into that resolution, so a disconnected provider still lands on the - // "Not connected" pill without a second local rule here. - const pillState = pillStateFor(viewModel); - const pillLastSyncAt = pillTimestampFor(viewModel); - // v1.4.43 W14 — `parked` and `error` both want the underlying error - // message surfaced under the pill (the pill says "what" — the - // message says "why"). Other states leave the inline line off. - const errorMessage = - (pillState === "error" || - pillState === "parked" || - pillState === "warning") && - viewModel?.lastError - ? viewModel.lastError - : null; - - return ( - - - } - status={ - - } - /> - -
    - {errorMessage && } - {/* v1.4.25 W5d — Withings activity-scope reconnect banner. - Only paints when (a) the user has an active Withings - connection (so the credentials are already saved + a token - exists) AND (b) the persisted scope is missing - `user.activity`. Reconnecting takes the user through the - standard /api/withings/connect → Withings → callback flow; - the callback persists the upgraded scope and the banner - vanishes on the next status poll. */} - {status?.connected && status?.hasActivityScope === false && ( - - - {t("settings.integrations.withings.reconnect.banner.title")} - - - {t("settings.integrations.withings.reconnect.banner.body")} - - - {t("settings.integrations.withings.reconnect.banner.action")} - {" →"} - - - )} - {/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only - when the row state is `parked` (>24h of persistent - failures). The button POSTs to /api/integrations/withings/ - resume which calls `resumeIntegrationFromPark`; on success - the per-card status invalidates and the pill flips back to - connected without a page refresh. The button is the - primary action the user can take from this card — wider - tap target than the inline action row so it's reachable - on a Pixel 5 viewport. */} - {pillState === "parked" && ( -
    - - {t("settings.integrationPill.parkedReconnect")} - - -
    - )} - {resume.isError && ( -

    - {t("settings.integrationPill.resumeError")} -

    - )} - - {status?.connected && ( - - )} - - {status?.connected && ( - - )} - {resume.isSuccess && resume.data?.wasParked && ( -

    - {t("settings.integrationPill.resumeSuccess")} -

    - )} -
    -

    - {t("settings.withingsCredentials")} -

    - {!status?.configured && ( - - )} -
    - {/* v1.4.27 MB7 / CF-53 — the credentials grid drops from - a 3-column row (client-id / secret / save) to a - 2-column row of inputs at `sm:`, with the Save button - lifted out into its own row below. The previous - "invisible Label" hack to align the button with the - input baselines fell apart on Galaxy Fold; lifting - the button into a dedicated `flex justify-end` row - gives it consistent placement on every viewport and - lets the input pair span the full width. */} -
    -
    - - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.withingsCredentialsSavedPlaceholder") - : t("settings.withingsClientId") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="next" - /> -
    -
    - - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t("settings.withingsCredentialsSavedPlaceholderSecret") - : t("settings.withingsClientSecret") - } - maxLength={200} - autoComplete="off" - inputMode="text" - spellCheck={false} - autoCapitalize="none" - enterKeyHint="done" - /> -
    -
    - - - - {credsMsg && ( -

    - {credsMsg} -

    - )} -
    -
    - - {status?.connected ? ( - <> - {/* v1.4.27 MB7 / CF-57 — the action row already wraps via - `flex-wrap`, but on Pixel 5 the four AlertDialog + - test-connection triggers each took a fractional slot - that read jagged. Force each button to a `min-w-[10rem]` - on ` - - - - - - - - - {t("settings.withingsFullSyncTitle")} - - - {t("settings.withingsFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.withingsSynchronize")} - - - - - - - - - - - - - {t("settings.withingsDisconnectTitle")} - - - {t("settings.withingsDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.withingsDisconnect")} - - - - -
    - {syncResult && ( - - )} - {/* connect→data loop: a discreet link to where this provider's - readings now surface — doubles as the "your data is richer" - cue. */} - - {t("settings.withingsViewData")} - - - - ) : status?.configured ? ( - - - - ) : ( -

    - {t("settings.withingsNoCredentials")} -

    - )} - - -
    - ); -} diff --git a/src/components/settings/integrations/withings-plan-note.tsx b/src/components/settings/integrations/withings-plan-note.tsx deleted file mode 100644 index 433dfcab4..000000000 --- a/src/components/settings/integrations/withings-plan-note.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { ExternalLink } from "lucide-react"; - -import { useTranslations } from "@/lib/i18n/context"; -import { - WITHINGS_API_PLANS_URL, - type WithingsPlanFeature, -} from "@/lib/withings/plan-features"; - -/** - * A calm note on the Withings card when the account's API plan withholds some - * readings (status 277 since the 2026-10-12 plan split). Not an error: the - * sync skips those readings and keeps importing the rest, so this sits in the - * card body in the info wash, never in the pill. - */ -export function WithingsPlanNote({ - features, -}: { - features: readonly WithingsPlanFeature[] | undefined; -}) { - const { t, locale } = useTranslations(); - if (!features || features.length === 0) return null; - - const labels: Record = { - spo2: t("settings.integrations.withings.plan.items.spo2"), - vascularAge: t("settings.integrations.withings.plan.items.vascularAge"), - pulseWaveVelocity: t( - "settings.integrations.withings.plan.items.pulseWaveVelocity", - ), - vo2Max: t("settings.integrations.withings.plan.items.vo2Max"), - visceralFat: t("settings.integrations.withings.plan.items.visceralFat"), - skinTemperature: t( - "settings.integrations.withings.plan.items.skinTemperature", - ), - ecg: t("settings.integrations.withings.plan.items.ecg"), - sleepScore: t("settings.integrations.withings.plan.items.sleepScore"), - sleepHrv: t("settings.integrations.withings.plan.items.sleepHrv"), - sleepHeartRate: t( - "settings.integrations.withings.plan.items.sleepHeartRate", - ), - sleepRespiratoryRate: t( - "settings.integrations.withings.plan.items.sleepRespiratoryRate", - ), - sleepSpo2: t("settings.integrations.withings.plan.items.sleepSpo2"), - bodyTemperature: t( - "settings.integrations.withings.plan.items.bodyTemperature", - ), - bodyFat: t("settings.integrations.withings.plan.items.bodyFat"), - fatMass: t("settings.integrations.withings.plan.items.fatMass"), - fatFreeMass: t("settings.integrations.withings.plan.items.fatFreeMass"), - muscleMass: t("settings.integrations.withings.plan.items.muscleMass"), - bodyWater: t("settings.integrations.withings.plan.items.bodyWater"), - boneMass: t("settings.integrations.withings.plan.items.boneMass"), - heartRate: t("settings.integrations.withings.plan.items.heartRate"), - bloodPressure: t("settings.integrations.withings.plan.items.bloodPressure"), - weight: t("settings.integrations.withings.plan.items.weight"), - }; - const list = new Intl.ListFormat(locale, { - style: "long", - type: "conjunction", - }).format(features.map((f) => labels[f])); - - return ( -
    -

    - {t("settings.integrations.withings.plan.title")} -

    -

    {t("settings.integrations.withings.plan.body", { list })}

    - - {t("settings.integrations.withings.plan.link")} - -
    - ); -} diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx index ee2756674..b878f2a89 100644 --- a/src/components/settings/managed-integration-status.tsx +++ b/src/components/settings/managed-integration-status.tsx @@ -1,28 +1,27 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { Plug } from "lucide-react"; +import { Droplet, ExternalLink, Plug } from "lucide-react"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; -import { SettingsCard } from "@/components/settings/settings-card"; +import { IntegrationCard } from "@/components/settings/integrations/integration-card"; +import { OAUTH_INTEGRATIONS } from "@/components/settings/integrations/oauth-integration-card"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { SectionHeading } from "@/components/ui/section-heading"; import { apiGet } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; import { queryKeys } from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings"; -type ManagedIntegrationState = - "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; +type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES; interface ManagedIntegrationStatusResponse { recordId: string; integrations: Array<{ - integration: keyof typeof INTEGRATION_DISPLAY_NAMES; - state: ManagedIntegrationState; - lastSuccessAt: string | null; - lastAttemptAt: string | null; + integration: ManagedIntegrationKey; + /** The server-resolved state; null while the provider is not connected. */ + health: ConnectionHealth | null; }>; } @@ -37,25 +36,24 @@ const INTEGRATION_DISPLAY_NAMES = { strava: "Strava", } as const; -function recordStatusPillState(state: ManagedIntegrationState) { - switch (state) { - case "connected": - return "connected" as const; - case "error_transient": - return "warning" as const; - case "error_reauth": - return "error" as const; - case "parked": - return "parked" as const; - case "disconnected": - return "disconnected" as const; - } +/** Where the guardian boundary is explained, connected services included. */ +const SHARING_DOCS_HREF = + "https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"; + +function iconFor(key: ManagedIntegrationKey) { + if (key === "nightscout") return Droplet; + return OAUTH_INTEGRATIONS[key]?.icon ?? Plug; } /** - * The managed-record integration view is deliberately a status projection. - * It reads the active record's id from the resolved account-access payload and - * has no connection, credential, sync, or provider-control affordance. + * The managed record's connections, on the same card every integration in + * Settings renders, from the same server-resolved `health`. + * + * Read-only by construction: a guardian cannot connect, reconnect, resume, + * sync, test or disconnect a managed record's provider, because an OAuth flow + * cannot safely carry a record selector. So the cards carry no menu and no + * button, and one sentence above them says why, with the page that explains + * the boundary. */ export function ManagedIntegrationStatus() { const { t } = useTranslations(); @@ -78,60 +76,58 @@ export function ManagedIntegrationStatus() { return void statusQuery.refetch()} />; } - if (statusQuery.isLoading || !statusQuery.data) { - return ( - - -

    - {t("settings.sharedRecord.integrationStatusDetail")} -

    -
    + const loading = statusQuery.isLoading || !statusQuery.data; + const entries = + statusQuery.data?.integrations ?? + (Object.keys(INTEGRATION_DISPLAY_NAMES) as ManagedIntegrationKey[]).map( + (integration) => ({ integration, health: null }), ); - } return ( - - +

    + {t("settings.sharedRecord.integrationStatusDetail")}{" "} + + {t("settings.sharedRecord.integrationStatusDocs")} + +

      - {statusQuery.data.integrations.map((integration) => ( -
    • - - {INTEGRATION_DISPLAY_NAMES[integration.integration]} - - ( +
    • +
    • ))}
    -
    + ); } From d7588fe9bf3d1d65c48e4b0d183237e961aaa01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:10:18 +0200 Subject: [PATCH 21/40] fix(sleep-score): place each night's midpoint on its main session A nap on the same wake day moved the night's latest instant to the end of the nap and added its minutes, so the midpoint of a regular night landed around noon. A few such days scored consistency at 0, and a nap after the latest night scored timing at 0 as well. The night now carries its main session (wake instant and asleep minutes), and the midpoint is read from that alone. Sleep rhythm and chronotype read the same midpoint. --- src/lib/analytics/sleep-night.ts | 16 +++++++ .../derived/__tests__/sleep-score.test.ts | 48 +++++++++++++++++++ src/lib/insights/derived/sleep-score.ts | 14 ++++-- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 3d5997189..cbc3dc4a1 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -316,6 +316,15 @@ export interface SleepNight { * "sources disagree" hint. */ sourceDiscrepancy: SleepSourceDiscrepancy | null; + /** + * The night's MAIN session alone: the same-wake-day session with the most + * asleep minutes (the one `sourceDiscrepancy` reads), its wake instant and + * its own asleep minutes. A nap on the same wake day is in the night's + * totals but not here, so a reader that places the night on the clock (the + * sleep midpoint) is not dragged into the afternoon by it. Null when no + * session carries asleep minutes. + */ + mainSession: { end: Date; asleepMinutes: number } | null; } /** Sentinel for rows that carry no source — collapses to one bucket. */ @@ -738,6 +747,13 @@ export function reconstructSleepNights( sourceDiscrepancy: mainSessionRows ? sessionSourceDiscrepancy(mainSessionRows) : null, + mainSession: + mainSessionRows && mainSessionAsleep > 0 + ? { + end: new Date(mainSessionEnd), + asleepMinutes: mainSessionAsleep, + } + : null, }); } return nights.sort((a, b) => (a.night < b.night ? -1 : 1)); diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index c464868a3..ef12bce2d 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -186,6 +186,21 @@ describe("reconstructNights", () => { expect(nights[0].midpoint).toBe(180); }); + it("places the midpoint on the main session, not on a nap the same wake day", () => { + // Overnight 23:00 to 07:00 (480 asleep minutes, midpoint 03:00) and an + // afternoon nap 14:00 to 15:00 that wakes on the same day. The night's + // totals carry the nap; its place on the clock must not. + const rows = night("2026-06-02", [ + ["CORE", 60, "00:00"], + ["CORE", 420, "07:00"], + ["CORE", 60, "15:00"], + ]); + const nights = reconstructNights(rows); + expect(nights).toHaveLength(1); + expect(nights[0].asleepMinutes).toBe(540); + expect(nights[0].midpoint).toBe(3 * 60); + }); + it("expresses the midpoint in the user's timezone when one is passed", () => { // Same 03:00Z midpoint, read in Asia/Kolkata (UTC+5:30, no DST) → // 08:30 local = 510 min-of-day, not 180. @@ -282,6 +297,39 @@ describe("reconstructNights", () => { }); describe("computeSleepScore", () => { + it("keeps consistency and timing on a steady sleeper who naps some afternoons", async () => { + // Seven nights 23:00 to 07:00, every other wake day with a 14:00 to 15:00 + // nap. The overnight rhythm never moves, so consistency and timing are + // perfect; reading the midpoint off the night's latest instant put the nap + // days about seven hours late and scored both at 0. + const rows: ReturnType = []; + for (let i = 0; i < 7; i += 1) { + const wake = new Date(Date.UTC(2026, 4, 27 + i)); + const day = wake.toISOString().slice(0, 10); + rows.push( + ...night(day, [ + ["IN_BED", 480, "07:00"], + ["CORE", 240, "03:00"], + ["DEEP", 120, "05:00"], + ["REM", 120, "07:00"], + ]), + ); + if (i % 2 === 0) rows.push(...night(day, [["CORE", 60, "15:00"]])); + } + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { + now: new Date("2026-06-02T16:00:00Z"), + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + const byKey = Object.fromEntries( + result.value.subScores.map((sub) => [sub.key, sub.value]), + ); + expect(byKey.consistency).toBe(100); + expect(byKey.timing).toBe(100); + } + }); + it("returns insufficient when no sleep rows exist", async () => { findMany.mockResolvedValue([]); const result = await computeSleepScore("u1", PROFILE, { now: NOW }); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 6a00dd7ff..37c8fec6c 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -392,13 +392,17 @@ export function reconstructNights( : n.asleepMinutes > 0 && awakeMinutes > 0 ? n.asleepMinutes + awakeMinutes : null; - // Midpoint = centre of the asleep span. The canonical night's `measuredAt` - // is the wake instant (latest stage END); the asleep span is - // `asleepMinutes` long ending there, so its centre is wake − asleep/2. + // Midpoint = centre of the MAIN session's asleep span: its wake instant + // minus half its own asleep minutes. Not the whole night's: a nap on the + // same wake day moves the night's latest instant to the end of the nap and + // adds its minutes, which put the midpoint of a 23:00 to 07:00 night with a + // 14:00 nap at about 11:00 and read every such day as a ~7-hour shift in + // consistency and timing. + const main = n.mainSession; const midpoint = - n.asleepMinutes > 0 + main && main.asleepMinutes > 0 ? minutesOfDay( - new Date(n.measuredAt.getTime() - (n.asleepMinutes / 2) * 60_000), + new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000), tz, ) : null; From 35a33ca173cb80329a3fad4ea1c7425e6a784763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:10:18 +0200 Subject: [PATCH 22/40] fix(oura): skip rejected and deleted sleep periods Oura returns detected periods the person dismissed (type rest) and ones they deleted in the sleep collection. They were imported as sleep, with their lowest heart rate, HRV and breathing rate, which added an extra session to the day. They now map to nothing, and rows an earlier sync stored for such a period are soft-deleted on the next sync through the shared write tail. --- .../measurement-write-tail-guard.test.ts | 2 + src/lib/oura/__tests__/client.test.ts | 18 +++ src/lib/oura/__tests__/sync.test.ts | 69 +++++++++++ src/lib/oura/client.ts | 30 ++++- src/lib/oura/sync.ts | 63 ++++++++++ tests/integration/oura-retired-sleep.test.ts | 116 ++++++++++++++++++ 6 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 tests/integration/oura-retired-sleep.test.ts diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index 061f49718..4deacda2d 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -51,6 +51,8 @@ const WRITE_SITES: Record = { "lib/measurements/create-from-telegram.ts": TAIL, "lib/whoop/sync-body.ts": TAIL, "lib/whoop/webhook-handler.ts": TAIL, + // Retiring the rows of Oura sleep periods the person rejected or deleted. + "lib/oura/sync.ts": TAIL, // Integration syncs: each collects the (type, day) pairs it touched and // runs both legs itself at the end of the sync. "lib/fitbit/sync-core.ts": diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index ec294e1f9..b8e6b0893 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -330,6 +330,24 @@ describe("mapReadiness", () => { }); describe("mapSleep", () => { + it("maps a rejected (`rest`) or deleted period to nothing", () => { + const base: OuraSleep = { + id: "rec-r", + day: "2026-06-10", + bedtime_start: "2026-06-10T14:00:00.000Z", + bedtime_end: "2026-06-10T15:00:00.000Z", + sleep_phase_5_min: "222222222222", + lowest_heart_rate: 58, + average_hrv: 40, + }; + expect(mapSleep({ ...base, type: "rest" })).toEqual([]); + expect(mapSleep({ ...base, type: "deleted" })).toEqual([]); + // Periods Oura counts still map, a nap included. + for (const type of ["long_sleep", "sleep", "late_nap", undefined]) { + expect(mapSleep({ ...base, type }).length).toBeGreaterThan(0); + } + }); + it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => { const s: OuraSleep = { id: "rec-9", diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 691199cee..299f6e892 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -415,6 +415,75 @@ describe("syncUserOura", () => { ); }); + it("imports nothing for a rejected or deleted sleep period and retires what an earlier sync stored for it", async () => { + getConnMock.mockResolvedValue(CONN); + fetchSleepMock.mockResolvedValue([ + { + id: "rec-rest", + type: "rest", + day: "2026-06-10", + bedtime_start: "2026-06-10T14:00:00.000Z", + bedtime_end: "2026-06-10T15:00:00.000Z", + sleep_phase_5_min: "222222222222", + lowest_heart_rate: 58, + }, + { + id: "rec-del", + type: "deleted", + day: "2026-06-09", + bedtime_end: "2026-06-09T16:00:00.000Z", + light_sleep_duration: 1800, + }, + ]); + const stored = [ + { + id: "m1", + type: "SLEEP_DURATION", + measuredAt: new Date("2026-06-10T15:00:00.000Z"), + }, + { + id: "m2", + type: "RESTING_HEART_RATE", + measuredAt: new Date("2026-06-10T15:00:00.000Z"), + }, + ]; + findManyMock.mockResolvedValue(stored); + updateManyMock.mockResolvedValue({ count: 2 }); + + await syncUserOura("u1"); + + // Nothing of either period reaches the write path. + expect( + createdRows().filter((r) => String(r.externalId).startsWith("sleep:")), + ).toEqual([]); + // The rows of both periods are looked up by their record prefix, any type. + const lookup = findManyMock.mock.calls + .map((c) => c[0] as { where: Record }) + .find((a) => Array.isArray(a.where.OR)); + expect(lookup).toBeDefined(); + expect(lookup!.where).toMatchObject({ + userId: "u1", + source: "OURA", + deletedAt: null, + }); + expect(lookup!.where.type).toBeUndefined(); + expect(lookup!.where.OR).toEqual([ + { externalId: { startsWith: "sleep:rec-rest:" } }, + { externalId: { startsWith: "sleep:rec-del:" } }, + ]); + // ...and soft-deleted by id, then their rollup days recomputed. + const retire = updateManyMock.mock.calls + .map((c) => c[0] as { where: { id?: { in: string[] } }; data: unknown }) + .find((a) => a.where.id); + expect(retire?.where.id?.in).toEqual(["m1", "m2"]); + expect(retire?.data).toEqual({ deletedAt: expect.any(Date) }); + expect(recomputeMock).toHaveBeenCalledWith( + "u1", + "RESTING_HEART_RATE", + stored[1]!.measuredAt, + ); + }); + it("never sweeps when the sleep collection returns no records", async () => { getConnMock.mockResolvedValue(CONN); fetchReadinessMock.mockResolvedValue([ diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 44b9e9087..876b8d40b 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -309,7 +309,11 @@ export interface OuraCollection { export interface OuraSleep { id: string; day: string; - /** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */ + /** + * `long_sleep` | `sleep` | `late_nap` | `rest` | `deleted`. `rest` is a + * detected period the person rejected and `deleted` one they removed; see + * `isRetiredOuraSleepPeriod`. + */ type?: string | null; bedtime_start?: string; bedtime_end?: string; @@ -796,13 +800,35 @@ function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { } /** - * Map one Oura sleep record. When the real `sleep_phase_5_min` hypnogram is + * Oura sleep periods that are not sleep. Oura's `type` marks a detected period + * the person turned down in the app's confirm prompt as `rest` ("falsely + * detected sleep / nap, rejected by the user") and one they deleted as + * `deleted`; the sleep collection still returns both. Importing them made an + * afternoon on the sofa a second sleep session on that day, which added its + * minutes to the night and pulled the night's midpoint into the afternoon. + * `sleep`, `long_sleep` and `late_nap` are periods Oura counts, and an unknown + * or missing type stays in, as before. + */ +const RETIRED_OURA_SLEEP_TYPES: ReadonlySet = new Set([ + "rest", + "deleted", +]); + +/** True for an Oura sleep period the person rejected or deleted. */ +export function isRetiredOuraSleepPeriod(s: Pick): boolean { + return typeof s.type === "string" && RETIRED_OURA_SLEEP_TYPES.has(s.type); +} + +/** + * Map one Oura sleep record. A rejected or deleted period + * (`isRetiredOuraSleepPeriod`) maps to nothing. When the real `sleep_phase_5_min` hypnogram is * present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured * timeline); otherwise we fall back to the four per-stage totals stamped on the * sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and * respiratory rate. */ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] { + if (isRetiredOuraSleepPeriod(s)) return []; const measuredAt = dayAnchor(s.day, tz, s.bedtime_end); if (!measuredAt) return []; const out: MappedMeasurement[] = []; diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index d9d764f53..51a785ef3 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -52,6 +52,7 @@ import { type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; @@ -72,6 +73,7 @@ import { fetchResilience, fetchSleep, fetchVo2Max, + isRetiredOuraSleepPeriod, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -245,6 +247,12 @@ export interface OuraFetchResult { * run-indexed `seg:` row — before the fresh set upserts. */ sleepSweeps: SleepSegmentSweep[]; + /** + * The `sleep::` prefix of every fetched sleep period Oura marks as + * rejected or deleted (`isRetiredOuraSleepPeriod`). Such a period maps to no + * rows, and whatever an earlier sync imported for it is retired. + */ + sleepRetirements: string[]; /** * Collections refused with 401 while a sibling succeeded with the same * token: the token is fine, the grant does not cover them. Not failures; the @@ -335,6 +343,7 @@ async function fetchAll( // upsert. A collection that throws pushes nothing (the fetch is its first // await), so a 401-retry cannot double-enter a record. const sleepSweeps: SleepSegmentSweep[] = []; + const sleepRetirements: string[] = []; const collections: OuraCollectionSpec[] = [ { @@ -354,6 +363,10 @@ async function fetchAll( const records = await fetchSleep(t, query); const ups: OuraMeasurementUpsert[] = []; for (const s of records) { + if (isRetiredOuraSleepPeriod(s)) { + if (s.id) sleepRetirements.push(`sleep:${s.id}:`); + continue; + } const rows = toUpsert( mapSleep(s, tz), "sleep", @@ -480,6 +493,7 @@ async function fetchAll( readings: first.readings, ...splitScopeDenied(first.failures, first.succeeded > 0), sleepSweeps, + sleepRetirements, }; } @@ -492,6 +506,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, + sleepRetirements, scopeDenied: null, }; @@ -510,6 +525,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, + sleepRetirements, scopeDenied: null, }; @@ -520,9 +536,55 @@ async function fetchAll( // proves the grant as much as one that succeeded after it. ...splitScopeDenied(retry.failures, first.succeeded + retry.succeeded > 0), sleepSweeps, + sleepRetirements, }; } +/** + * Retire what earlier syncs imported for sleep periods Oura now reports as + * rejected or deleted: every live OURA row keyed under the period's + * `sleep::` prefix, whatever its type, because the period's stage + * rows and its nightly scalars (lowest heart rate, HRV, breathing rate, + * efficiency) all came from a period that was not sleep. Soft delete, then the + * shared write tail refolds the touched days and re-warms the touched types. + * Best-effort like the segment sweep: a failure never fails the sync, and the + * record stays inside the lookback window, so the next run retries it. + */ +export async function retireOuraSleepRecords( + userId: string, + prefixes: readonly string[], +): Promise { + // A bare `sleep::` (no record id) would match nothing useful and must never + // widen to the whole source. + const bounded = prefixes.filter((p) => /^sleep:[^:]+:$/.test(p)); + if (bounded.length === 0) return 0; + try { + const rows = await prisma.measurement.findMany({ + where: { + userId, + source: "OURA", + deletedAt: null, + OR: bounded.map((prefix) => ({ externalId: { startsWith: prefix } })), + }, + select: { id: true, type: true, measuredAt: true }, + }); + if (rows.length === 0) return 0; + const res = await prisma.measurement.updateMany({ + where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + await afterMeasurementMutation(userId, rows, "oura"); + invalidateUserMeasurements(userId); + annotate({ meta: { oura_sleep_retired_rows: res.count } }); + return res.count; + } catch (err) { + getEvent()?.addWarning( + `oura: retiring rejected sleep periods failed for ${userId}: ${err}`, + ); + return 0; + } +} + /** * Sync one user's Oura data. * @@ -589,6 +651,7 @@ export async function syncUserOura( // sync; the record stays inside the lookback window, so the next tick // retries it. await sweepStaleSleepSegments(userId, "OURA", result.sleepSweeps); + await retireOuraSleepRecords(userId, result.sleepRetirements); // Import everything the healthy collections returned regardless of whether a // sibling collection failed — one bad collection must not blank the source. diff --git a/tests/integration/oura-retired-sleep.test.ts b/tests/integration/oura-retired-sleep.test.ts new file mode 100644 index 000000000..8dfaf2e39 --- /dev/null +++ b/tests/integration/oura-retired-sleep.test.ts @@ -0,0 +1,116 @@ +/** + * Oura sleep periods the person rejected (`rest`) or deleted: against the + * real schema, retiring one soft-deletes every live OURA row under its record + * prefix, whatever the type, leaves a counted sleep period and the other + * sources alone, and recomputes the rollup day so the day stops counting the + * retired reading. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-oura-retired-sleep"; +const END = new Date(Date.UTC(2026, 5, 10, 15, 0, 0)); +const NIGHT_END = new Date(Date.UTC(2026, 5, 10, 7, 0, 0)); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: "oura-retired-sleep", + email: "oura-retired-sleep@example.test", + }, + }); + const row = ( + type: "SLEEP_DURATION" | "RESTING_HEART_RATE", + externalId: string, + value: number, + measuredAt: Date, + source: "OURA" | "APPLE_HEALTH" = "OURA", + ) => ({ + userId: USER, + type, + value, + unit: type === "SLEEP_DURATION" ? "minutes" : "bpm", + measuredAt, + source, + externalId, + sleepStage: type === "SLEEP_DURATION" ? ("CORE" as const) : null, + }); + await prisma.measurement.createMany({ + data: [ + // The rejected afternoon period: a stage row and its nightly scalar. + row( + "SLEEP_DURATION", + "sleep:rec-rest:seg:2026-06-10T14:00:00.000Z", + 60, + END, + ), + row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 58, END), + // The real night before it, same day. + row( + "SLEEP_DURATION", + "sleep:rec-night:seg:2026-06-09T23:00:00.000Z", + 480, + NIGHT_END, + ), + row("RESTING_HEART_RATE", "sleep:rec-night:rhr", 48, NIGHT_END), + // Another source's row whose id happens to share the prefix shape. + row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 52, END, "APPLE_HEALTH"), + ], + }); + await recomputeBucketsForMeasurement(USER, "RESTING_HEART_RATE", END); +}); + +describe("retireOuraSleepRecords", () => { + it("retires only the rejected period's OURA rows and recomputes the day", async () => { + const prisma = getPrismaClient(); + const { retireOuraSleepRecords } = await import("@/lib/oura/sync"); + + const before = await prisma.measurementRollup.findFirst({ + where: { + userId: USER, + type: "RESTING_HEART_RATE", + granularity: "DAY", + source: "OURA", + }, + }); + expect(before?.count).toBe(2); + + const removed = await retireOuraSleepRecords(USER, ["sleep:rec-rest:"]); + expect(removed).toBe(2); + + const live = await prisma.measurement.findMany({ + where: { userId: USER, deletedAt: null }, + select: { externalId: true, source: true }, + }); + const key = (r: { externalId: string | null; source: string }) => + `${r.source}|${r.externalId}`; + expect(live.sort((a, b) => key(a).localeCompare(key(b)))).toEqual([ + { externalId: "sleep:rec-rest:rhr", source: "APPLE_HEALTH" }, + { externalId: "sleep:rec-night:rhr", source: "OURA" }, + { + externalId: "sleep:rec-night:seg:2026-06-09T23:00:00.000Z", + source: "OURA", + }, + ]); + + const after = await prisma.measurementRollup.findFirst({ + where: { + userId: USER, + type: "RESTING_HEART_RATE", + granularity: "DAY", + source: "OURA", + }, + }); + expect(after?.count).toBe(1); + expect(after?.mean).toBe(48); + + // A malformed prefix never widens to the whole source. + expect(await retireOuraSleepRecords(USER, ["sleep::", "sleep:"])).toBe(0); + }); +}); From 73922c039e5832ee0e8fbf1e77b83fd1f7015bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:10:18 +0200 Subject: [PATCH 23/40] fix(insights): say what fills the recovery page when it is empty The empty note told a connected Oura account to connect Oura. The page charts the WHOOP and Polar recharge, strain and load readings and the Oura resilience level; Oura's readiness is the recovery score linked above. The note now says so. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- .../insights/__tests__/device-score-surfaces.test.tsx | 6 ++++++ 8 files changed, 13 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index e7a8a1dd0..f2047ee02 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4569,7 +4569,7 @@ "description": "Wie gut sich dein Körper erholt hat: die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", "scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen", "emptyTitle": "Noch keine Erholungsdaten", - "empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.", + "empty": "Hier ist noch nichts zu sehen. Diese Seite zeigt die Werte zu Aufladung, Belastung und Trainingslast, die WHOOP und Polar aufzeichnen, und Ouras Resilienz-Stufe. Ouras Readiness erscheint als dein Erholungs-Score, oben verlinkt.", "block": { "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet, ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", "dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.", diff --git a/messages/en.json b/messages/en.json index 11a517a80..b747870e1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4569,7 +4569,7 @@ "description": "How well your body has bounced back: the recovery, strain and load signals your wearable records overnight.", "scoreCrossLink": "See the composite recovery score", "emptyTitle": "No recovery data yet", - "empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.", + "empty": "Nothing to chart here yet. This page shows the recharge, strain and load readings that WHOOP and Polar record, and Oura's resilience level. Oura's readiness appears as your recovery score, linked above.", "block": { "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead, a recharge reading drawn from your overnight heart and breathing signals.", "dayStrainExplainer": "The day's whole-body cardiovascular load on your device's strain scale. Read it against your recovery, not as a goal on its own.", diff --git a/messages/es.json b/messages/es.json index 43c808b32..4e32fe8b7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4569,7 +4569,7 @@ "description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.", "scoreCrossLink": "Ver la puntuación de recuperación combinada", "emptyTitle": "Aún no hay datos de recuperación", - "empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.", + "empty": "Aún no hay nada que mostrar aquí. Esta página muestra las lecturas de recarga, esfuerzo y carga que registran WHOOP y Polar, y el nivel de resiliencia de Oura. La preparación (readiness) de Oura aparece como tu puntuación de recuperación, enlazada arriba.", "block": { "ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día: una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.", "dayStrainExplainer": "La carga cardiovascular del día en la escala de esfuerzo de tu dispositivo. Léela en relación con tu recuperación, no como una meta en sí.", diff --git a/messages/fr.json b/messages/fr.json index 3f3de9ecd..f289f27d1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4569,7 +4569,7 @@ "description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.", "scoreCrossLink": "Voir le score de récupération composite", "emptyTitle": "Pas encore de données de récupération", - "empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.", + "empty": "Rien à afficher ici pour l'instant. Cette page montre les mesures de recharge, d'effort et de charge qu'enregistrent WHOOP et Polar, ainsi que le niveau de résilience d'Oura. La disponibilité (readiness) d'Oura apparaît comme votre score de récupération, en lien ci-dessus.", "block": { "ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée : une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.", "dayStrainExplainer": "La charge cardiovasculaire de la journée sur l'échelle d'effort de votre appareil. Lisez-la au regard de votre récupération, pas comme un objectif en soi.", diff --git a/messages/it.json b/messages/it.json index 4853184ed..8c7248c57 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4569,7 +4569,7 @@ "description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.", "scoreCrossLink": "Vedi il punteggio di recupero composito", "emptyTitle": "Ancora nessun dato di recupero", - "empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.", + "empty": "Qui non c'è ancora nulla da mostrare. Questa pagina mostra i valori di ricarica, sforzo e carico che registrano WHOOP e Polar, e il livello di resilienza di Oura. La prontezza (readiness) di Oura compare come punteggio di recupero, collegato qui sopra.", "block": { "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata: una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.", "dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.", diff --git a/messages/ko.json b/messages/ko.json index 122c25d92..2d8147b90 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4569,7 +4569,7 @@ "description": "몸이 얼마나 회복했는지: 웨어러블이 밤사이 기록한 회복·부하 신호예요.", "scoreCrossLink": "종합 회복 점수 보기", "emptyTitle": "아직 회복 데이터가 없어요", - "empty": "아직 회복 데이터가 없어요. WHOOP, Polar, Oura 계정을 연결하면 기기가 동기화될 때 점수가 채워져요.", + "empty": "아직 여기에 표시할 내용이 없어요. 이 페이지는 WHOOP과 Polar가 기록하는 충전, 부하, 운동량 값과 Oura의 회복 탄력성 단계를 보여줘요. Oura의 준비도(Readiness)는 위에 연결된 회복 점수에 표시돼요.", "block": { "ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.", "dayStrainExplainer": "기기의 부하 척도로 본 하루 전체의 심혈관 부하예요. 그 자체를 목표로 삼지 말고 회복과 견줘 보세요.", diff --git a/messages/pl.json b/messages/pl.json index 5907e3c21..db438f05c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4569,7 +4569,7 @@ "description": "Jak dobrze Twój organizm się zregenerował: sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.", "scoreCrossLink": "Zobacz łączny wynik regeneracji", "emptyTitle": "Brak danych o regeneracji", - "empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.", + "empty": "Na razie nie ma tu nic do pokazania. Ta strona pokazuje odczyty ładowania, wysiłku i obciążenia, które rejestrują WHOOP i Polar, oraz poziom odporności z Oura. Gotowość (readiness) z Oura pojawia się jako wynik regeneracji, link powyżej.", "block": { "ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień: odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.", "dayStrainExplainer": "Dzienne obciążenie sercowo-naczyniowe na skali wysiłku Twojego urządzenia. Odczytuj je w odniesieniu do regeneracji, nie jako cel sam w sobie.", diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index b97208e08..5b26ba04a 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -206,6 +206,12 @@ describe(" data-gating", () => { // old hand-rolled div — the empty title comes from the unified primitive. expect(html).toContain("border-dashed"); expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"'); + // An Oura account sees this note too: it must say where Oura's readiness + // lives rather than tell a connected user to connect Oura. + expect(html).not.toContain("Connect a WHOOP, Polar or Oura account"); + expect(html).toContain( + "Oura's readiness appears as your recovery score", + ); }); it("renders a canonical chart block for the present signal", () => { From 648bb0d9858cb09f279c41f93af9823ee685f464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:10:53 +0200 Subject: [PATCH 24/40] fix(medications): walk intakes to the drug-level horizon, read every symptom in a window, and guard fixed client fetch limits --- .../client-fetch-limit-guard.test.ts | 207 ++++++++++++++++++ .../__tests__/drug-level-chart.test.tsx | 52 +++++ .../medications/drug-level-chart.tsx | 59 ++++- .../__tests__/load-symptom-events.test.ts | 67 ++++++ src/components/symptoms/use-symptoms.ts | 54 ++++- 5 files changed, 431 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/client-fetch-limit-guard.test.ts create mode 100644 src/components/symptoms/__tests__/load-symptom-events.test.ts diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts new file mode 100644 index 000000000..fcd8cffa3 --- /dev/null +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -0,0 +1,207 @@ +/** + * No client list or picker with a fixed row cap and nothing to reach past it. + * + * A client read that asks for "the newest N" and renders the answer as the + * whole list loses everything beyond N without a word: the Labs list asked + * for 500 raw rows and grouped them in the browser, so on a long history + * every marker measured only long ago had no row at all. The visit-form lab + * picker was the same class before it, and the GLP-1 curve, the side-effect + * log and the symptom timeline each had a quieter copy. + * + * The rule: every literal `limit=` a client fetch sends either sits + * beside a pager (an `offset` / `cursor` / `pageParam` within a few lines, or + * a `useInfiniteQuery` in the file), or is listed below with the reason the + * cap cannot cut anything off. + * + * What it matches: `limit=` inside a URL string, and the two + * `URLSearchParams` spellings, `.set("limit", "")` and + * `limit: ""`. What it does not: a limit routed through a named + * constant (`limit=${PAGE_SIZE}`), which is a deliberate page size in every + * case today. It walks client code only (`components/`, `hooks/`, and `app/` + * outside `app/api/`); a server-side `take` is not a fetch. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +interface Allowed { + file: string; + limit: number; + reason: string; +} + +const ALLOWLIST: readonly Allowed[] = [ + { + file: "app/labs/page.tsx", + limit: 1, + reason: + "Deep-link resolution: the newest reading under one analyte name, used only to find its marker page.", + }, + { + file: "components/onboarding/first-result-screen.tsx", + limit: 1, + reason: "Asks only whether a type has a reading, and which is newest.", + }, + { + file: "components/insights/glp1-substance-curve-summary.tsx", + limit: 1, + reason: + "Reads only `medications`, which lists every GLP-1 medication whatever the entry cap; the entries are not used.", + }, + { + file: "components/charts/chart-series-request.ts", + limit: 5000, + reason: + "Daily rollup buckets: one row per day, and the widest window (All, 3,650 days, plus a 365-day comparison) is 4,015 days.", + }, + { + file: "components/insights/trend-annotation.tsx", + limit: 5000, + reason: "Daily rollup buckets over a 30-day window: at most 31 rows.", + }, + { + file: "components/insights/coach-panel/attachment-picker.tsx", + limit: 50, + reason: + "Search-backed picker: the query narrows the whole document vault server-side.", + }, + { + file: "components/settings/share-document-picker.tsx", + limit: 50, + reason: + "Search-backed picker: the query narrows the whole document vault server-side.", + }, + { + file: "components/documents/document-link-picker.tsx", + limit: 50, + reason: + "Search-backed picker: the query narrows the whole document vault server-side.", + }, + { + file: "hooks/use-mood-day.ts", + limit: 50, + reason: + "One calendar day of mood entries; fifty check-ins in a day is beyond any cadence the app offers.", + }, +]; + +const LIMIT_PATTERNS: readonly RegExp[] = [ + /[?&]limit=(\d+)/g, + /\.set\(\s*["']limit["']\s*,\s*["'](\d+)["']\s*\)/g, + /\blimit:\s*["'](\d+)["']/g, +]; + +const PAGER_NEARBY = /\boffset\b|\bcursor\b|\bpageParam\b/; +const PAGER_LINES = 3; + +function clientFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter( + (p) => + p.startsWith("components/") || + p.startsWith("hooks/") || + (p.startsWith("app/") && !p.startsWith("app/api/")), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !/\.test\.tsx?$/.test(p)) + .sort(); +} + +interface Hit { + file: string; + line: number; + limit: number; + paged: boolean; +} + +/** Every literal limit in the given source, with whether a pager is beside it. */ +function findLimitHits(file: string, source: string): Hit[] { + const lines = source.split("\n"); + const infinite = /\buseInfiniteQuery\b/.test(source); + const hits: Hit[] = []; + lines.forEach((text, index) => { + // Comments describe old shapes; they send nothing. + const trimmed = text.trim(); + if (trimmed.startsWith("//") || trimmed.startsWith("*")) return; + for (const pattern of LIMIT_PATTERNS) { + for (const match of text.matchAll(pattern)) { + const near = lines + .slice(Math.max(0, index - PAGER_LINES), index + PAGER_LINES + 1) + .join("\n"); + hits.push({ + file, + line: index + 1, + limit: Number(match[1]), + paged: infinite || PAGER_NEARBY.test(near), + }); + } + } + }); + return hits; +} + +function allHits(): Hit[] { + return clientFiles().flatMap((file) => + findLimitHits(file, readFileSync(join(SRC, file), "utf8")), + ); +} + +function isAllowed(hit: Hit): boolean { + return ALLOWLIST.some((a) => a.file === hit.file && a.limit === hit.limit); +} + +describe("client fetch limits", () => { + it("finds the literal limits it is meant to police", () => { + // An empty sweep would agree with any allowlist. Every allowlisted cap is + // a live hit, so the hit set can never be smaller than the list. + expect(allHits().length).toBeGreaterThanOrEqual(ALLOWLIST.length); + }); + + it("puts every fixed limit beside a pager or on the allowlist", () => { + const offenders = allHits() + .filter((hit) => !hit.paged && !isAllowed(hit)) + .map((hit) => `${hit.file}:${hit.line} limit=${hit.limit}`); + expect( + offenders, + "A client fetch with a fixed limit loses everything past it. Page it (offset / cursor / useInfiniteQuery), move the grouping server-side, or add an allowlist entry that says why the cap cannot cut anything off.", + ).toEqual([]); + }); + + it("keeps no allowlist entry that no longer matches anything", () => { + const hits = allHits(); + const stale = ALLOWLIST.filter( + (a) => !hits.some((h) => h.file === a.file && h.limit === a.limit), + ).map((a) => `${a.file} limit=${a.limit}`); + expect(stale).toEqual([]); + }); + + it("gives every allowlist entry a reason", () => { + for (const entry of ALLOWLIST) { + expect(entry.reason.length, entry.file).toBeGreaterThan(20); + } + }); + + it("flags the shape that dropped markers from the Labs list", () => { + const hits = findLimitHits( + "components/labs/lab-list.tsx", + 'apiGet("/api/labs?limit=500&sortDir=desc")', + ); + expect(hits).toHaveLength(1); + expect(hits[0].paged).toBe(false); + expect(isAllowed(hits[0])).toBe(false); + }); + + it("accepts a limit that travels with an offset", () => { + const hits = findLimitHits( + "components/example.tsx", + "fetch(`/api/x?status=taken&limit=100&offset=${offset}`)", + ); + expect(hits).toHaveLength(1); + expect(hits[0].paged).toBe(true); + }); +}); diff --git a/src/components/medications/__tests__/drug-level-chart.test.tsx b/src/components/medications/__tests__/drug-level-chart.test.tsx index a509dbcd3..9e9b0b673 100644 --- a/src/components/medications/__tests__/drug-level-chart.test.tsx +++ b/src/components/medications/__tests__/drug-level-chart.test.tsx @@ -81,6 +81,7 @@ vi.mock("@/lib/medications/glp1-pk", async () => { import { DrugLevelChart, + loadTakenIntakesSince, parseDoseMg, resolveDoseMg, } from "../drug-level-chart"; @@ -362,3 +363,54 @@ describe("parseDoseMg / resolveDoseMg helpers", () => { expect(resolveDoseMg("not-an-iso", [], 42)).toBe(42); }); }); + +describe("loadTakenIntakesSince", () => { + const DAY = 24 * 60 * 60 * 1000; + // 400 daily taken doses, newest first, the newest one a day ago. + const now = Date.parse("2026-06-01T08:00:00.000Z"); + const all = Array.from({ length: 400 }, (_, i) => ({ + id: `i-${i}`, + takenAt: new Date(now - (i + 1) * DAY).toISOString(), + skipped: false, + scheduledFor: new Date(now - (i + 1) * DAY).toISOString(), + })); + + function pager() { + const urls: string[] = []; + const fetchPage = async (url: string) => { + urls.push(url); + const params = new URL(url, "http://localhost").searchParams; + const limit = Number(params.get("limit")); + const offset = Number(params.get("offset")); + return { + events: all.slice(offset, offset + limit), + meta: { total: all.length }, + }; + }; + return { urls, fetchPage }; + } + + it("walks back past a single page to the horizon, and no further", async () => { + const { urls, fetchPage } = pager(); + // 21-day window + 5 half-lives of 7 days = 56 days, then 150 to cross a page. + const since = new Date(now - 150.5 * DAY); + const { events } = await loadTakenIntakesSince("med-1", since, fetchPage); + expect(events).toHaveLength(150); + expect(urls).toHaveLength(2); + for (const url of urls) { + expect(url).toContain("status=taken"); + expect(url).toContain("sortBy=takenAt"); + } + }); + + it("stops at the end of the history", async () => { + const { urls, fetchPage } = pager(); + const { events } = await loadTakenIntakesSince( + "med-1", + new Date(now - 10_000 * DAY), + fetchPage, + ); + expect(events).toHaveLength(400); + expect(urls).toHaveLength(4); + }); +}); diff --git a/src/components/medications/drug-level-chart.tsx b/src/components/medications/drug-level-chart.tsx index 481b65748..86e2fdcd7 100644 --- a/src/components/medications/drug-level-chart.tsx +++ b/src/components/medications/drug-level-chart.tsx @@ -56,6 +56,7 @@ import { } from "@/lib/medications/glp1-pk"; import { findDrugIdByBrand, + GLP1_DRUGS, type Glp1DrugId, } from "@/lib/medications/glp1-knowledge"; import { parseDoseMg } from "@/lib/medications/dose-string"; @@ -74,6 +75,53 @@ const HOURS_PER_DAY = 24; const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY; const WINDOW_HOURS_AFTER = 0; +/** Taken intakes per request while walking back to the model's horizon. */ +const INTAKE_PAGE_SIZE = 100; +/** + * Half-lives before the chart's left edge whose doses still count. After five + * half-lives a dose holds about 3% of its peak, below what the chart can show. + */ +const HORIZON_HALF_LIVES = 5; + +/** + * Every taken intake from `since` on, newest first, walked page by page. The + * curve is a sum over every dose still in the body, so a fixed count of + * recent intakes was wrong in both directions: twenty daily doses of a + * long-acting drug stop well short of steady state, and planned or skipped + * rows used up the count without being doses at all. + */ +export async function loadTakenIntakesSince( + medicationId: string, + since: Date, + fetchPage: (url: string) => Promise<{ + events: IntakeEvent[]; + meta: { total: number }; + }> = (url) => apiGet(url), +): Promise<{ events: IntakeEvent[] }> { + const events: IntakeEvent[] = []; + for (let offset = 0; ; offset += INTAKE_PAGE_SIZE) { + const page = await fetchPage( + `/api/medications/${medicationId}/intake?status=taken&sortBy=takenAt&sortDir=desc&limit=${INTAKE_PAGE_SIZE}&offset=${offset}`, + ); + events.push(...page.events); + const oldest = page.events[page.events.length - 1]; + const reachedHorizon = + !oldest?.takenAt || Date.parse(oldest.takenAt) < since.getTime(); + if ( + reachedHorizon || + page.events.length < INTAKE_PAGE_SIZE || + offset + INTAKE_PAGE_SIZE >= page.meta.total + ) { + break; + } + } + return { + events: events.filter( + (ev) => ev.takenAt && Date.parse(ev.takenAt) >= since.getTime(), + ), + }; +} + interface IntakeEvent { id: string; takenAt: string | null; @@ -147,9 +195,16 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { queryKey: queryKeys.medicationIntakeDrugLevelChart(medication.id), queryFn: async () => { try { - return await apiGet<{ events: IntakeEvent[] }>( - `/api/medications/${medication.id}/intake?limit=20&sortBy=takenAt&sortDir=desc`, + const halfLifeHours = + GLP1_DRUGS[drugId!].pharmacology.halfLifeDays * HOURS_PER_DAY; + const since = new Date( + Date.now() - + (WINDOW_HOURS_BEFORE + HORIZON_HALF_LIVES * halfLifeHours) * + 60 * + 60 * + 1000, ); + return await loadTakenIntakesSince(medication.id, since); } catch { return null; } diff --git a/src/components/symptoms/__tests__/load-symptom-events.test.ts b/src/components/symptoms/__tests__/load-symptom-events.test.ts new file mode 100644 index 000000000..4a9098f35 --- /dev/null +++ b/src/components/symptoms/__tests__/load-symptom-events.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import type { SymptomEventDTO } from "@/lib/symptoms/shared"; +import { loadSymptomEvents, type SymptomEventList } from "../use-symptoms"; + +/** + * A fake of `GET /api/symptoms/events`: newest first, `to` inclusive, at most + * `limit` rows, `hasMore` when the page is full. + */ +function fakeRoute(all: SymptomEventDTO[]) { + const urls: string[] = []; + const fetchPage = async (url: string): Promise => { + urls.push(url); + const params = new URL(url, "http://localhost").searchParams; + const to = params.get("to")!; + const limit = Number(params.get("limit")); + const rows = all + .filter((e) => e.occurredAt <= to) + .sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1)) + .slice(0, limit); + return { events: rows, limit, hasMore: rows.length === limit }; + }; + return { urls, fetchPage }; +} + +function event(i: number, occurredAt: string): SymptomEventDTO { + return { id: `e-${i}`, occurredAt } as SymptomEventDTO; +} + +describe("loadSymptomEvents", () => { + it("reads past the first page until the window is exhausted", async () => { + const base = Date.parse("2026-06-01T00:00:00.000Z"); + // 1,200 occurrences an hour apart, two of them sharing a page boundary. + const all = Array.from({ length: 1_200 }, (_, i) => + event(i, new Date(base - i * 3_600_000).toISOString()), + ); + all.push(event(9_999, all[499].occurredAt)); + const { urls, fetchPage } = fakeRoute(all); + + const out = await loadSymptomEvents( + "1900-01-01T00:00:00.000Z", + "2026-06-02T00:00:00.000Z", + "ep-1", + fetchPage, + ); + + expect(out.events).toHaveLength(1_201); + expect(new Set(out.events.map((e) => e.id)).size).toBe(1_201); + expect(out.hasMore).toBe(false); + expect(urls.length).toBeGreaterThan(2); + expect(urls.every((u) => u.includes("episodeId=ep-1"))).toBe(true); + }); + + it("stops after one request when the first page is not full", async () => { + const { urls, fetchPage } = fakeRoute([ + event(1, "2026-05-01T00:00:00.000Z"), + ]); + const out = await loadSymptomEvents( + "2026-04-01T00:00:00.000Z", + "2026-06-01T00:00:00.000Z", + null, + fetchPage, + ); + expect(out.events).toHaveLength(1); + expect(urls).toHaveLength(1); + }); +}); diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index a95ebaed2..9c900f10c 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -55,9 +55,55 @@ export function useSymptomDefinitions(includeHidden = false, enabled = true) { }); } +/** Occurrences per request while walking a window. */ +const SYMPTOM_EVENTS_PAGE_SIZE = 500; + +/** + * Every occurrence inside `[from, to]` (ISO instants), newest first, read page + * by page. The route answers at most one page and says `hasMore`; each + * further page asks up to the oldest instant already read (`to` is + * inclusive, so the boundary instant comes back and is dropped by id). A + * chronic episode can hold more than one page, and a single capped read + * showed only its newest part. + */ +export async function loadSymptomEvents( + from: string, + to: string, + episodeId: string | null, + fetchPage: (url: string) => Promise = (url) => + apiGet(url), +): Promise { + const seen = new Set(); + const events: SymptomEventDTO[] = []; + let upTo = to; + for (;;) { + const params = new URLSearchParams({ + from, + to: upTo, + limit: String(SYMPTOM_EVENTS_PAGE_SIZE), + }); + if (episodeId) params.set("episodeId", episodeId); + const page = await fetchPage(`/api/symptoms/events?${params}`); + let added = 0; + for (const event of page.events) { + if (seen.has(event.id)) continue; + seen.add(event.id); + events.push(event); + added += 1; + } + const oldest = page.events[page.events.length - 1]; + // A page that adds nothing (a full page of one instant) cannot move the + // bound, so it ends the walk rather than repeating forever. + if (!page.hasMore || !oldest || added === 0) { + return { events, limit: SYMPTOM_EVENTS_PAGE_SIZE, hasMore: false }; + } + upTo = oldest.occurredAt; + } +} + /** * Occurrences inside `[from, to]` (ISO instants), optionally only those filed - * against one episode. At most 500, newest first. + * against one episode. Every one of them, newest first. */ export function useSymptomEvents( from: string, @@ -68,11 +114,7 @@ export function useSymptomEvents( return useQuery({ queryKey: queryKeys.symptomEvents(from, to, episodeId), enabled: options.enabled ?? true, - queryFn: () => { - const params = new URLSearchParams({ from, to, limit: "500" }); - if (episodeId) params.set("episodeId", episodeId); - return apiGet(`/api/symptoms/events?${params}`); - }, + queryFn: () => loadSymptomEvents(from, to, episodeId), }); } From eab4d644f7e20a0e2525557c3b9c247915a01bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:12:57 +0200 Subject: [PATCH 25/40] feat(ops): log pending migrations before migrate deploy and document a dump before every update --- Dockerfile | 10 +- docker-entrypoint.sh | 3 + docs/ops/deploy.md | 6 ++ docs/self-hosting/getting-started.md | 39 ++++++++ scripts/pending-migrations.mjs | 95 +++++++++++++++++++ .../container-scripts-ship-guard.test.ts | 30 ++++++ .../pending-migrations-notice.test.ts | 78 +++++++++++++++ 7 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 scripts/pending-migrations.mjs create mode 100644 tests/integration/pending-migrations-notice.test.ts diff --git a/Dockerfile b/Dockerfile index 475e51861..a06be1896 100644 --- a/Dockerfile +++ b/Dockerfile @@ -202,14 +202,20 @@ COPY --from=builder /app/src/generated ./src/generated # `@node-rs/argon2` from the traced runtime tree. Scripts that import the # application graph (`@/…`, the generated Prisma client) run from a source # checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds -# the runbooks and this list in step. Root-owned on purpose: the app user can -# run them but not rewrite them. +# the runbooks and the entrypoint against this list. `pending-migrations.mjs` +# is the entrypoint's own: it logs the pending migration count before +# `migrate deploy`. Root-owned on purpose: the app user can run them but not +# rewrite them. COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs +COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts # Prove the reset CLI's imports resolve in this tree: with every import found # it stops at its own usage line; a missing module throws before reaching it. RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:" +# The boot notice parses, and the `pg` it loads on a real boot resolves here. +RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \ + node --input-type=module -e "await import('pg')" # Install the migration CLI, its config dependency, and a pinned launcher for # the maintenance scripts already shipped in the standalone tree. The Prisma diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 3386a8879..8eff71a6b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -85,6 +85,9 @@ done echo "HealthLog: Database is reachable." # ── Run Prisma migrations ──────────────────────────────────── +# Say how many migrations this image is about to apply, with a reminder to +# back up first. Reporting only: it never stops the boot. +node scripts/pending-migrations.mjs || true echo "HealthLog: Running database migrations..." NODE_PATH=/opt/prisma-cli/node_modules \ node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 94f0c047d..1bf418cdc 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -13,6 +13,12 @@ Migrations land **before** the new app container takes traffic. The canonical command sequence on a single-instance host: ```sh +# 0. Dump the database first. A migration is not undone by going back to +# the previous image; the dump is the way back. See "Updates" in +# docs/self-hosting/getting-started.md. +docker compose exec -T db pg_dump -U healthlog healthlog \ + | gzip > healthlog-before-update-$(date +%F).sql.gz + # 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF, # set it to the new one first. docker compose pull app diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 9967227e6..983a8267f 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -361,3 +361,42 @@ For off-host encrypted backups to S3/R2/B2 see [`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the encryption key safely (keep the old key until zero rows remain on it) see [`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md). + +## Updates + +A new image can bring database migrations, and `docker-entrypoint.sh` +applies them on the first start of that image. Most are additive, but some +rewrite data, and going back to the previous image does not undo them. The +only way back from a migration that went wrong is the dump you took before +it. So take one before every pull: + +```bash +# 1. Dump the database (same command as under "Backup and restore"). +docker compose exec -T db pg_dump -U healthlog healthlog \ + | gzip > healthlog-before-update-$(date +%F).sql.gz + +# 2. Pull the new image and recreate the app container. +docker compose pull app +docker compose up -d app + +# 3. Confirm the running build. +curl -s http://localhost:3000/api/version +``` + +Keep the dump until the new version has run for a few days, and keep your +encryption key with it: a dump without the key cannot be read. + +The start log says what is about to happen before the migrations run: + +```text +HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image. +HealthLog: Running database migrations... +``` + +or `HealthLog: Database schema is up to date.` when the image brings none. +The line is a notice and does not pause the start. If you see a count and +have no dump yet, stop the container (`docker compose stop app`), take the +dump, and start it again; the migrations run on that start. + +For the ordering on a host where you want the migration result before the +new container takes traffic, see [`docs/ops/deploy.md`](../ops/deploy.md). diff --git a/scripts/pending-migrations.mjs b/scripts/pending-migrations.mjs new file mode 100644 index 000000000..387e062dc --- /dev/null +++ b/scripts/pending-migrations.mjs @@ -0,0 +1,95 @@ +/** + * Boot-time notice before `prisma migrate deploy`: how many migrations this + * image is about to apply, and a reminder to have a backup first. + * + * Run by `docker-entrypoint.sh` as `node scripts/pending-migrations.mjs`. + * It only reports. A migration that changes data cannot be undone by pulling + * the previous image, so an operator who reads the count in the log still has + * a moment to stop the container and take a `pg_dump`; the entrypoint does + * not wait for that. + * + * Best effort by design: any failure to count (no `_prisma_migrations` table + * yet is not a failure, it means every migration is pending) prints one line + * saying so and exits 0, because a notice is never a reason to keep the app + * from starting. `migrate deploy` right after it is what decides. + */ +import { readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** Migration directory names that carry a `migration.sql`, sorted. */ +export function shippedMigrations(migrationsDir) { + return readdirSync(migrationsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => existsSync(join(migrationsDir, name, "migration.sql"))) + .sort(); +} + +/** + * The shipped migrations the database has not finished applying. Reads + * `_prisma_migrations` in the schema the connection string names (Prisma's + * `?schema=` parameter, `public` when absent); a database without that table + * has applied nothing. + */ +export async function pendingMigrations({ connectionString, migrationsDir }) { + const shipped = shippedMigrations(migrationsDir); + const { default: pg } = await import("pg"); + const url = new URL(connectionString); + const schema = url.searchParams.get("schema") || "public"; + url.searchParams.delete("schema"); + const client = new pg.Client({ + connectionString: url.toString(), + connectionTimeoutMillis: 5_000, + statement_timeout: 5_000, + }); + await client.connect(); + try { + const table = await client.query( + "SELECT to_regclass(format('%I._prisma_migrations', $1::text)) AS t", + [schema], + ); + if (!table.rows[0]?.t) return shipped; + const applied = await client.query( + `SELECT migration_name FROM ${table.rows[0].t} + WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`, + ); + const done = new Set(applied.rows.map((row) => row.migration_name)); + return shipped.filter((name) => !done.has(name)); + } finally { + await client.end().catch(() => {}); + } +} + +/** The log line for a count, as the entrypoint prints it. */ +export function pendingNotice(count) { + if (count === 0) return "HealthLog: Database schema is up to date."; + const noun = count === 1 ? "migration" : "migrations"; + return ( + `HealthLog: ${count} database ${noun} pending. ` + + "Back up the database before updating (pg_dump, see the Updates section " + + "of docs/self-hosting/getting-started.md); a migration is not undone by " + + "going back to the previous image." + ); +} + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) return; + try { + const pending = await pendingMigrations({ + connectionString, + migrationsDir: join(process.cwd(), "prisma", "migrations"), + }); + console.log(pendingNotice(pending.length)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.log( + `HealthLog: Could not count pending migrations (${reason}); continuing.`, + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/src/__tests__/container-scripts-ship-guard.test.ts b/src/__tests__/container-scripts-ship-guard.test.ts index 83e2e928f..629a62f48 100644 --- a/src/__tests__/container-scripts-ship-guard.test.ts +++ b/src/__tests__/container-scripts-ship-guard.test.ts @@ -69,6 +69,18 @@ function documentedContainerScripts(): string[] { return [...found].sort(); } +/** Scripts `docker-entrypoint.sh` runs with `node scripts/…` on every boot. */ +function entrypointScripts(): string[] { + const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8"); + return [ + ...new Set( + [...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map( + (m) => m[1], + ), + ), + ].sort(); +} + /** Relative import specifiers of a script, resolved against its location. */ function localImports(script: string): string[] { const src = readFileSync(join(ROOT, script), "utf8"); @@ -109,3 +121,21 @@ describe("scripts the runbooks run inside the container ship in the image", () = expect(missing).toEqual([]); }); }); + +describe("scripts the entrypoint runs ship in the image", () => { + it("finds the boot scripts it is meant to check", () => { + // An empty match would agree with any Dockerfile. + expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs"); + }); + + it("copies every boot script and its local imports into the runner stage", () => { + const missing = entrypointScripts() + .flatMap((script) => [script, ...localImports(script)]) + .filter((path) => !copiedIntoRunner(path)); + expect( + missing, + "docker-entrypoint.sh runs these on every boot, but the Dockerfile " + + "runner stage does not COPY them; the container would fail to start.", + ).toEqual([]); + }); +}); diff --git a/tests/integration/pending-migrations-notice.test.ts b/tests/integration/pending-migrations-notice.test.ts new file mode 100644 index 000000000..3631e6612 --- /dev/null +++ b/tests/integration/pending-migrations-notice.test.ts @@ -0,0 +1,78 @@ +/** + * The boot notice `docker-entrypoint.sh` prints before `migrate deploy` + * (`scripts/pending-migrations.mjs`), against the migrated test database. + * + * Every shipped migration is applied here, so the count is zero; a + * migrations directory with one more entry than the database has applied + * stands for a freshly pulled image, and a schema with no migrations table + * for a first boot. + */ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + pendingMigrations, + pendingNotice, + shippedMigrations, +} from "../../scripts/pending-migrations.mjs"; + +const MIGRATIONS = join(process.cwd(), "prisma", "migrations"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true }); +}); + +function connectionString(): string { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set for the integration run"); + return url; +} + +describe("pending-migrations boot notice", () => { + it("counts nothing pending on a database that has applied every migration", async () => { + const pending = await pendingMigrations({ + connectionString: connectionString(), + migrationsDir: MIGRATIONS, + }); + expect(shippedMigrations(MIGRATIONS).length).toBeGreaterThan(300); + expect(pending).toEqual([]); + expect(pendingNotice(pending.length)).toBe( + "HealthLog: Database schema is up to date.", + ); + }); + + it("counts a migration the image ships and the database has not applied", async () => { + const dir = mkdtempSync(join(tmpdir(), "hl-migrations-")); + tempDirs.push(dir); + cpSync(MIGRATIONS, dir, { recursive: true }); + mkdirSync(join(dir, "9999_not_applied_yet")); + writeFileSync(join(dir, "9999_not_applied_yet", "migration.sql"), ""); + // A directory without migration.sql is not a migration. + mkdirSync(join(dir, "9998_no_sql")); + + const pending = await pendingMigrations({ + connectionString: connectionString(), + migrationsDir: dir, + }); + expect(pending).toEqual(["9999_not_applied_yet"]); + const notice = pendingNotice(pending.length); + expect(notice).toContain("1 database migration pending"); + expect(notice).toContain("pg_dump"); + }); + + it("treats a schema without a migrations table as a first boot", async () => { + const url = new URL(connectionString()); + url.searchParams.set("schema", "schema_never_migrated"); + const pending = await pendingMigrations({ + connectionString: url.toString(), + migrationsDir: MIGRATIONS, + }); + expect(pending).toEqual(shippedMigrations(MIGRATIONS)); + expect(pendingNotice(pending.length)).toMatch( + /^HealthLog: \d+ database migrations pending\./, + ); + }); +}); From 91ccd154b1a031f4d215ad06c09818ecd4067db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:19:46 +0200 Subject: [PATCH 26/40] test(labs): register the overview route in the sharing and module-gate inventories --- src/__tests__/sharing-surface-guard.test.ts | 9 ++++++++- .../api/__tests__/module-route-gate-inventory.test.ts | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 50035b43b..6d627ac32 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -847,6 +847,10 @@ const DELEGABLE_ROUTES: Record = { domain: "labs", why: "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.", }, + "app/api/labs/overview/route.ts": { + domain: "labs", + why: "The Labs list's per-marker overview of the record: the newest reading of each biomarker, its count and a short trend. The same rows and the same biomarker include as `GET /api/labs`, grouped server-side; read only.", + }, "app/api/biomarkers/route.ts": { domain: "labs", why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.", @@ -2097,8 +2101,11 @@ const ACTOR_ROUTES: Record = { * v1.42 — the score-history read joins the record list and the manage * literal (a score's daily course, MANAGE like the derived score itself). * 252 -> 254. + * + * v1.42.4 -- the Labs list's per-marker overview joins the record list + * beside `GET /api/labs`, the rows it groups. 255 -> 256. */ -const FROZEN_ENTRY_COUNT = 255; +const FROZEN_ENTRY_COUNT = 256; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 64d8ba407..431e119ca 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -242,6 +242,8 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/workouts/batch/route.ts", "src/app/api/labs/route.ts", "src/app/api/labs/[id]/route.ts", + // The Labs list's per-marker overview reads the same row store. + "src/app/api/labs/overview/route.ts", // v1.18.1 — the lab-result delete-Undo restore endpoint and the // user-scoped Biomarker catalog CRUD share the LabResult data-layer // reasoning: the labs module gates the SURFACES (the Labs page), not the From eedd91ec64f0fc1a6c8c8fc6564b6ca359277a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:21:49 +0200 Subject: [PATCH 27/40] test(e2e): drive the integration cards through every health state One mocked page carries a provider in each state, checked at desktop width and at 390 px by data attributes, with the menu driven from the keyboard, the copied diagnostics compared with the shown text, and an axe pass in both themes. The solid destructive badge replaces the red-on-red wash, which fell below AA. --- e2e/settings-integration-cards.spec.ts | 220 ++++++++++++++++ e2e/settings-integrations-mobile.spec.ts | 175 ++++++------ e2e/setup/integration-cards-fixture.ts | 248 ++++++++++++++++++ .../integrations/integration-card.tsx | 9 +- 4 files changed, 571 insertions(+), 81 deletions(-) create mode 100644 e2e/settings-integration-cards.spec.ts create mode 100644 e2e/setup/integration-cards-fixture.ts diff --git a/e2e/settings-integration-cards.spec.ts b/e2e/settings-integration-cards.spec.ts new file mode 100644 index 000000000..1663a1668 --- /dev/null +++ b/e2e/settings-integration-cards.spec.ts @@ -0,0 +1,220 @@ +import AxeBuilder from "@axe-core/playwright"; + +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + INTEGRATION_CARD_STATES, + mockIntegrationCards, +} from "./setup/integration-cards-fixture"; + +/** + * Settings → Integrations: the shared integration card, one card per state. + * + * Every provider is mocked into a different `health` (see the fixture), and + * the page is checked at desktop width and at 390 px. Assertions read data + * attributes only: the card's `data-state`, the badge's `data-state`, the one + * primary button's `data-action`. The wording is pinned by the component + * tests against the bundle itself. + * + * Runs in the desktop project and sizes the page itself, so one run covers + * both widths without a second project driving the same account. + */ +const VIEWPORTS = [ + { name: "desktop", width: 1280, height: 900 }, + { name: "390 px", width: 390, height: 844 }, +] as const; + +test.describe("Settings → Integrations: one card per health state", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "sizes the viewport itself", + ); + }); + + for (const viewport of VIEWPORTS) { + test(`renders each state from health alone at ${viewport.name}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await mockIntegrationCards(page); + await page.goto("/settings/integrations"); + + for (const [provider, expected] of Object.entries( + INTEGRATION_CARD_STATES, + )) { + const card = page.locator( + `[data-slot="integration-card"][data-integration="${provider}"]`, + ); + await expect(card, provider).toHaveAttribute( + "data-state", + expected.state, + { timeout: 15_000 }, + ); + + const badge = card.locator('[data-slot="integration-health-badge"]'); + if (expected.state === "not_connected") { + await expect(badge, provider).toHaveCount(0); + } else { + await expect(badge, provider).toHaveAttribute( + "data-state", + expected.state, + ); + // The word is the accessible name, not decoration. + await expect(badge, provider).not.toHaveText(""); + } + + // At most one primary, and exactly the one the state names. + const primary = card.locator( + '[data-slot="integration-primary-action"]', + ); + if (expected.primary === null) { + await expect(primary, provider).toHaveCount(0); + } else { + await expect(primary, provider).toHaveCount(1); + await expect(primary, provider).toHaveAttribute( + "data-action", + expected.primary, + ); + if (viewport.width < 640) { + const box = await primary.boundingBox(); + expect( + box?.height ?? 0, + `${provider} tap target`, + ).toBeGreaterThanOrEqual(44); + } + } + } + + // Nothing on the page states a status the old way. + await expect( + page.locator('[data-testid="integration-status-pill"]'), + ).toHaveCount(0); + + const dims = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + })); + expect( + dims.scrollWidth, + `scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); + }); + } + + test("the menu works from the keyboard and keeps its confirmations", async ({ + page, + }) => { + await mockIntegrationCards(page); + await page.goto("/settings/integrations"); + + const card = page.locator( + '[data-slot="integration-card"][data-integration="withings"]', + ); + const trigger = card.locator('[data-slot="integration-menu-trigger"]'); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.focus(); + await page.keyboard.press("Enter"); + const menu = page.locator('[data-slot="integration-menu"]'); + await expect(menu).toBeVisible(); + await expect( + menu.locator('[data-slot="integration-menu-item"]'), + ).toHaveCount(7); + + // Disconnect asks first; dismissing the dialog does nothing. + await menu.locator('[data-action="disconnect"]').focus(); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(card).toHaveAttribute("data-state", "limited"); + + // Escape closes the menu and returns focus to its trigger. + await trigger.focus(); + await page.keyboard.press("Enter"); + await expect(menu).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(trigger).toBeFocused(); + }); + + test("details open on request and Copy puts exactly the shown text on the clipboard", async ({ + page, + context, + }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await mockIntegrationCards(page); + await page.goto("/settings/integrations"); + + const card = page.locator( + '[data-slot="integration-card"][data-integration="polar"]', + ); + const toggle = card.locator('[data-slot="integration-details-toggle"]'); + const panel = card.locator('[data-slot="integration-details"]'); + await expect(toggle).toHaveAttribute("aria-expanded", "false", { + timeout: 15_000, + }); + await expect(panel).toBeHidden(); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(panel).toBeVisible(); + + const shown = await panel + .locator('[data-slot="integration-diagnostics"]') + .innerText(); + expect(shown).toContain("degraded / provider_errors"); + expect(shown).toContain("503"); + + await panel.locator('[data-slot="integration-diagnostics-copy"]').click(); + const status = panel.locator( + '[data-slot="integration-diagnostics-copy-status"]', + ); + await expect(status).toHaveAttribute("data-state", "copied"); + await expect(status).toHaveAttribute("role", "status"); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied.trim()).toBe(shown.trim()); + }); + + for (const theme of ["light", "dark"] as const) { + test(`every state reads accessibly in the ${theme} theme`, async ({ + page, + }) => { + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.addInitScript((explicit: string) => { + window.localStorage.setItem("healthlog-theme", explicit); + }, theme); + await mockIntegrationCards(page); + await page.goto("/settings/integrations"); + const panel = page.locator('[data-slot="connections-panel"]'); + await expect( + panel.locator( + '[data-slot="integration-card"][data-integration="whoop"]', + ), + ).toHaveAttribute("data-state", "action_required", { timeout: 15_000 }); + // Scan with one details panel open, so the diagnostics block and its + // copy button are part of what is checked. + await panel + .locator( + '[data-integration="polar"] [data-slot="integration-details-toggle"]', + ) + .click(); + + const results = await new AxeBuilder({ page }) + .include('[data-slot="connections-panel"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + const serious = results.violations + .filter((v) => v.impact === "serious" || v.impact === "critical") + .map( + (v) => + `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`, + ); + expect(serious).toEqual([]); + }); + } +}); diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index f827442c8..ffc6d2b8b 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -3,22 +3,16 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** - * v1.4.19 phase A5 — `/settings/integrations` on a Pixel-5 viewport - * (393 CSS px) must: + * `/settings/integrations` on a Pixel-5 viewport (393 CSS px) must: * - * 1. Not produce horizontal page overflow. The new - * IntegrationStatusPill is `whitespace-nowrap` so the chip - * itself can grow as wide as its longest possible label - * ("Verbunden · vor 12 min" / "Error — reconnect"); the parent - * header is a `flex-wrap` so the pill drops to its own line if - * the title and pill together exceed the card width — this spec - * verifies neither path leaks into a horizontal scrollbar on the - * element. + * 1. Not produce horizontal page overflow. The badge in each card header is + * `whitespace-nowrap`; the header row wraps so the badge drops under the + * title when both do not fit. Neither path may leak into a horizontal + * scrollbar on the element. * - * 2. Render exactly one pill per integration card. Belt-and-braces - * guard for the card consolidation — the Vitest spec covers this - * on the SSR markup level, but the Pixel-5 layout is what matters - * in practice. + * 2. Render exactly one status badge per connected card, and none on a card + * that is not connected. The Vitest spec covers this on the SSR markup; + * the Pixel-5 layout is what matters in practice. * * Mobile-only spec — desktop project skips it. */ @@ -29,7 +23,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => { test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); }); - test("Pixel-5 viewport: no horizontal page scroll, one pill per card", async ({ + test("Pixel-5 viewport: no horizontal page scroll, one badge per connected card", async ({ page, }) => { // Stub the three endpoints the Integrations section reads — both @@ -66,42 +60,56 @@ test.describe("/settings/integrations Pixel-5 layout", () => { integrations: [ { integration: "withings", - state: "connected", - lastSuccessAt: recent, - lastAttemptAt: recent, lastError: null, - consecutiveFailures: 0, - // v1.12.1 — the cards read connection state from this - // consolidated envelope (the per-provider routes were - // dropped from the section). The Withings pill keys on - // `connected`; a card-less pill keys on `configured`. configured: true, connected: true, - connectedAt: "2026-04-01T12:00:00Z", - legacyLastSyncedAt: recent, - tokenExpiresAt: "2026-12-01T12:00:00Z", - tokenExpired: false, - scope: "user.info,user.metrics,user.activity", - hasActivityScope: true, - syncHealth: { verdict: "fresh", since: null }, + health: { + state: "ok", + reason: "data_arriving", + since: null, + action: null, + unavailable: [], + lastSuccessAt: recent, + lastDataAt: recent, + diagnostics: { + ledgerState: "connected", + failingLegs: [], + failingSince: null, + lastAttemptAt: recent, + verdict: "fresh", + recentCalls: [], + }, + }, metricFreshness: [ { type: "WEIGHT", lastSeenAt: recent, stale: false }, ], }, { - // Configured, card-less, and no longer attempting: the case - // the fallback row exists for. Synthetic since v1.32.33 — - // every shipped provider has a card again, and the row must - // stay proven before the next one is dropped. + // Configured, card-less, and no longer syncing: the case the + // fallback row exists for. Synthetic since v1.32.33 — every + // shipped provider has a card again, and the row must stay + // proven before the next one is dropped. integration: "example-provider", - state: "connected", - lastSuccessAt: recent, - lastAttemptAt: recent, lastError: null, - consecutiveFailures: 0, configured: true, - legacyLastSyncedAt: monthAgo, - syncHealth: { verdict: "stalled", since: monthAgo }, + connected: true, + health: { + state: "degraded", + reason: "sync_stalled", + since: monthAgo, + action: null, + unavailable: [], + lastSuccessAt: monthAgo, + lastDataAt: monthAgo, + diagnostics: { + ledgerState: "connected", + failingLegs: [], + failingSince: null, + lastAttemptAt: monthAgo, + verdict: "stalled", + recentCalls: [], + }, + }, metricFreshness: [], }, // v1.28.x — Strava OAuth card reads off this consolidated @@ -109,30 +117,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => { // never resolves and the integrations page hangs. { integration: "strava", - state: "disconnected", - lastSuccessAt: null, - lastAttemptAt: null, lastError: null, connected: false, configured: false, available: false, hasOwnCredentials: false, - syncHealth: { verdict: "disconnected", since: null }, + health: null, metricFreshness: [], }, // Nightscout reads the envelope too since the card dropped its - // own status round-trip; without an entry its pill never resolves. + // own status round-trip; without an entry its card never settles. { integration: "nightscout", - state: "disconnected", - lastSuccessAt: null, - lastAttemptAt: null, lastError: null, connected: false, configured: false, hasToken: false, - allowPrivateHost: false, - syncHealth: { verdict: "disconnected", since: null }, + health: null, metricFreshness: [], }, ], @@ -142,6 +143,19 @@ test.describe("/settings/integrations Pixel-5 layout", () => { }), ); + // Apple Health has delivered nothing on this account: not connected, so + // no badge. Mocked so a seeded HealthKit row cannot add one. + await page.route("**/api/integrations/healthkit", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], lastSyncedAt: null, health: null }, + error: null, + }), + }), + ); + await page.route("**/api/withings/status", (route) => route.fulfill({ status: 200, @@ -164,42 +178,45 @@ test.describe("/settings/integrations Pixel-5 layout", () => { }); await page.waitForLoadState("networkidle"); - // Wait for the pills to mount (queries resolve after first paint). - // One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar, - // Oura, Strava, Nightscout — plus the fallback row for the configured, - // card-less entry. (The Garmin info note is not an OAuth card and - // carries no pill; the Apple Health card's pill keeps its own testid.) - const pills = page.locator('[data-testid="integration-status-pill"]'); - await expect(pills).toHaveCount(9, { timeout: 10_000 }); + // Wait for the cards to settle (queries resolve after first paint). + // Withings and the card-less fallback are connected: one badge each. + // Every provider without an envelope entry stays in its loading state, + // and the not-connected ones carry no badge at all. + const badges = page.locator('[data-slot="integration-health-badge"]'); + await expect(badges).toHaveCount(2, { timeout: 10_000 }); // The acceptance case: a configured integration with no card of its own, - // which has stopped even attempting, is visible on the page. + // which has stopped syncing, is visible on the page, read-only. const fallback = page.locator( '[data-testid="integration-fallback-row"][data-integration="example-provider"]', ); await expect(fallback).toHaveCount(1); await expect( - fallback.locator('[data-testid="integration-status-pill"]'), - ).toHaveAttribute("data-state", "stalled"); + fallback.locator('[data-slot="integration-health-badge"]'), + ).toHaveAttribute("data-state", "degraded"); + await expect( + fallback.locator('[data-slot="integration-menu-trigger"]'), + ).toHaveCount(0); // Card anatomy is uniform because no integration card paints a rule under - // its header any more. The `
    ` existed only in this folder — nine cards - // carrying a separator no other Settings card has — and the card's own gap - // does the separating now. + // its header any more. await expect( page.locator('[data-testid="integration-card-divider"]'), ).toHaveCount(0); - // Withings is connected in this fixture; WHOOP is a BYO-keys card that - // stays dormant until an operator adds credentials, so assert the one - // connected pill. await expect( page.locator( - '[data-testid="integration-status-pill"][data-state="connected"]', + '[data-slot="integration-card"][data-integration="withings"]', ), - ).toHaveCount(1); + ).toHaveAttribute("data-state", "ok"); + await expect( + page.locator('[data-slot="integration-card"][data-integration="strava"]'), + ).toHaveAttribute("data-state", "not_connected"); - // The redundant v1.4.18 banner must be gone. + // The retired status surfaces stay gone. + await expect( + page.locator('[data-testid="integration-status-pill"]'), + ).toHaveCount(0); await expect( page.locator('[data-testid="integration-status-banner"]'), ).toHaveCount(0); @@ -215,12 +232,11 @@ test.describe("/settings/integrations Pixel-5 layout", () => { `scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, ).toBeLessThanOrEqual(dims.innerWidth + 1); - // Pill text remains legible at this viewport — assert non-empty - // bounding rect so we know it's painted, not display:none-d by - // some surprising container query. + // Badge text remains legible at this viewport: painted, not hidden by a + // surprising container query. for (let i = 0; i < 2; i++) { - const box = await pills.nth(i).boundingBox(); - expect(box, `pill ${i} has no bounding box`).not.toBeNull(); + const box = await badges.nth(i).boundingBox(); + expect(box, `badge ${i} has no bounding box`).not.toBeNull(); expect(box!.width).toBeGreaterThan(0); expect(box!.height).toBeGreaterThan(0); } @@ -241,7 +257,8 @@ test.describe("Apple Health guidance", () => { data: { entries: [], lastSyncedAt: null, - syncHealth: { verdict: "pending_first_sync", since: null }, + // Nothing delivered yet: no health, so the card is not connected. + health: null, metricFreshness: [], // #778 — nothing accepted yet, so the card must show no // progress section for this pending-first-sync account. @@ -268,10 +285,10 @@ test.describe("Apple Health guidance", () => { // A raw key means the bundle lookup failed, which is the failure this // assertion can actually see from a browser. await expect(description).not.toContainText("settings.appleHealth"); - await expect(page.getByTestId("apple-health-status")).toHaveAttribute( - "data-state", - "pending-setup", - ); + await expect(card).toHaveAttribute("data-state", "not_connected"); + await expect( + card.locator('[data-slot="integration-health-badge"]'), + ).toHaveCount(0); const importLink = page.getByTestId("apple-health-import-link"); await expect(importLink).toHaveAttribute( diff --git a/e2e/setup/integration-cards-fixture.ts b/e2e/setup/integration-cards-fixture.ts new file mode 100644 index 000000000..501861174 --- /dev/null +++ b/e2e/setup/integration-cards-fixture.ts @@ -0,0 +1,248 @@ +import type { Page } from "@playwright/test"; + +/** + * Settings → Integrations with one card per health state, all mocked. + * + * Each provider carries the `health` the server would publish for one + * situation, so a single page shows every state the shared card can be in: + * + * withings limited / plan_limited (no button) + * oura limited / permission_limited (one Reconnect) + * fitbit ok / data_arriving (older history loading) + * polar degraded / provider_errors (no button) + * whoop action_required / paused (one Resume) + * google-health action_required / reauth (one Reconnect) + * strava not connected (Connect) + * nightscout not connected (address form) + * apple-health degraded / no_new_data + * + * Timestamps are relative to now, so the sentences read the same on any day. + */ +const HOUR = 60 * 60 * 1000; + +function ago(hours: number): string { + return new Date(Date.now() - hours * HOUR).toISOString(); +} + +function health(overrides: Record = {}) { + return { + state: "ok", + reason: "data_arriving", + since: null, + action: null, + unavailable: [], + lastSuccessAt: ago(1), + lastDataAt: ago(2), + diagnostics: { + ledgerState: "connected", + failingLegs: [], + failingSince: null, + lastAttemptAt: ago(1), + verdict: "fresh", + recentCalls: [ + { endpoint: "measures", status: "ok", at: ago(1), errorCode: null }, + ], + }, + ...overrides, + }; +} + +function connected(integration: string, h: Record) { + return { + integration, + lastError: null, + connected: true, + configured: true, + available: true, + hasOwnCredentials: true, + health: h, + metricFreshness: [ + { type: "WEIGHT", lastSeenAt: ago(2), stale: false }, + { type: "PULSE", lastSeenAt: ago(80), stale: true }, + ], + }; +} + +function disconnected(integration: string) { + return { + integration, + lastError: null, + connected: false, + configured: false, + available: true, + hasOwnCredentials: false, + health: null, + metricFreshness: [], + }; +} + +export const INTEGRATION_CARD_STATES = { + withings: { state: "limited", primary: null }, + oura: { state: "limited", primary: "reconnect" }, + fitbit: { state: "ok", primary: null }, + polar: { state: "degraded", primary: null }, + whoop: { state: "action_required", primary: "resume" }, + "google-health": { state: "action_required", primary: "reconnect" }, + strava: { state: "not_connected", primary: "connect" }, + nightscout: { state: "not_connected", primary: "connect" }, + "apple-health": { state: "degraded", primary: null }, +} as const; + +export function integrationCardsEnvelope() { + return { + threshold: 3, + metricFreshnessDegraded: false, + integrations: [ + connected( + "withings", + health({ + state: "limited", + reason: "plan_limited", + unavailable: [ + { item: "spo2", cause: "plan" }, + { item: "vascularAge", cause: "plan" }, + { item: "pulseWaveVelocity", cause: "plan" }, + { item: "vo2Max", cause: "plan" }, + { item: "ecg", cause: "plan" }, + ], + }), + ), + { + ...connected("whoop", { + ...health({ + state: "action_required", + reason: "paused_after_errors", + since: ago(50), + action: "resume", + lastDataAt: ago(52), + }), + diagnostics: { + ledgerState: "parked", + failingLegs: ["recovery", "sleep"], + failingSince: ago(50), + lastAttemptAt: ago(3), + verdict: "parked", + recentCalls: [ + { + endpoint: "recovery", + status: "persistent", + at: ago(3), + errorCode: "500", + }, + { + endpoint: "sleep", + status: "persistent", + at: ago(3), + errorCode: "500", + }, + ], + }, + }), + consecutiveFailuresByKind: { + transient: 0, + reauth_required: 0, + persistent: 4, + }, + }, + { + ...connected("fitbit", health()), + backfillCompleted: false, + }, + connected( + "google-health", + health({ + state: "action_required", + reason: "reauth_required", + action: "reconnect", + since: ago(30), + lastDataAt: ago(31), + }), + ), + connected( + "polar", + health({ + state: "degraded", + reason: "provider_errors", + since: ago(9), + lastDataAt: ago(20), + diagnostics: { + ledgerState: "error_transient", + failingLegs: ["sleep"], + failingSince: ago(9), + lastAttemptAt: ago(1), + verdict: "failing", + recentCalls: [ + { + endpoint: "sleep", + status: "transient", + at: ago(1), + errorCode: "503", + }, + { + endpoint: "sleep", + status: "transient", + at: ago(2), + errorCode: "503", + }, + { + endpoint: "activity", + status: "ok", + at: ago(2), + errorCode: null, + }, + ], + }, + }), + ), + connected( + "oura", + health({ + state: "limited", + reason: "permission_limited", + action: "reconnect", + unavailable: [ + { item: "spo2", cause: "permission" }, + { item: "vo2max", cause: "permission" }, + ], + }), + ), + disconnected("strava"), + { ...disconnected("nightscout"), hasToken: false }, + ], + }; +} + +export function appleHealthStatus() { + return { + entries: [], + lastSyncedAt: ago(60), + lastSyncTrigger: "foreground", + lastBackgroundSyncAt: null, + metricFreshness: [], + syncProgress: { recordsAccepted: 48211, oldestMeasuredAt: ago(24 * 900) }, + health: health({ + state: "degraded", + reason: "no_new_data", + since: ago(60), + lastDataAt: ago(60), + }), + }; +} + +/** Route every read the Integrations page makes onto the fixture. */ +export async function mockIntegrationCards(page: Page): Promise { + await page.route("**/api/integrations/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: integrationCardsEnvelope(), error: null }), + }), + ); + await page.route("**/api/integrations/healthkit", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: appleHealthStatus(), error: null }), + }), + ); +} diff --git a/src/components/settings/integrations/integration-card.tsx b/src/components/settings/integrations/integration-card.tsx index 451b563db..3a6cf059f 100644 --- a/src/components/settings/integrations/integration-card.tsx +++ b/src/components/settings/integrations/integration-card.tsx @@ -68,11 +68,16 @@ import { /** The app version the build stamped; empty in a dev build. */ const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ""; +/** + * The three calm states take the status token on a light wash. "Action + * needed" is the solid destructive badge: red text on a red wash falls below + * AA in both themes, and this is the one state that should stand out anyway. + */ const BADGE_TONE: Record = { ok: "border-success/30 bg-success/15 text-success", limited: "border-info/30 bg-info/15 text-info", degraded: "border-warning/30 bg-warning/15 text-warning", - action_required: "border-destructive/30 bg-destructive/15 text-destructive", + action_required: "", }; /** The status badge: a dot and one word. The word is the accessible name. */ @@ -82,7 +87,7 @@ export function HealthBadge({ state }: { state: ConnectionHealthState }) {