diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e0ba191f9..b2b354faa 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -20,6 +20,12 @@ import { import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; +import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; +import { + classifyMetricFreshness, + PUSH_CADENCE, + resolveSyncVerdict, +} from "@/lib/integrations/sync-verdict"; export const directionEnum = z.enum([ "bidirectional", @@ -129,10 +135,30 @@ export const GET = apiHandler(async () => { const stored = parseStored(row?.healthKitConfigJson ?? null); const entries = mergeWithDefaults(stored); + const lastSyncedAt = row?.healthKitLastSyncedAt?.toISOString() ?? null; + + // Apple Health is push-based: the iOS app delivers and nothing here polls, so + // liveness is a data question, not an attempt question. Without a threshold a + // three-week-dead pipe rendered the same green as one that delivered an hour + // ago. Fail-soft on the per-metric read — an honesty signal is never worth + // 500-ing the config the iOS client needs. + // No `connected` hint: Apple Health has nothing to connect or disconnect, so + // "never delivered" resolves to `pending_first_sync`, not `disconnected`. + const syncHealth = resolveSyncVerdict({ + configured: true, + lastDataAt: lastSyncedAt, + legacyLastSyncedAt: lastSyncedAt, + cadence: PUSH_CADENCE, + }); + const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch( + () => [], + ); return apiSuccess({ entries, - lastSyncedAt: row?.healthKitLastSyncedAt?.toISOString() ?? null, + lastSyncedAt, + syncHealth, + metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict), }); }); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index cbf53029b..cd17301b5 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -27,7 +27,14 @@ import { import { getSourceMetricFreshness, type MetricFreshnessEntry, + type MetricFreshnessSample, } from "@/lib/integrations/metric-freshness"; +import { + classifyMetricFreshness, + INTEGRATION_CADENCE, + resolveSyncVerdict, + type SyncHealth, +} from "@/lib/integrations/sync-verdict"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { getStravaClientCredentials } from "@/lib/strava/credentials"; @@ -49,6 +56,7 @@ export const GET = apiHandler(async () => { polarStatus, ouraStatus, stravaStatus, + nightscoutStatus, dbUser, withingsConn, whoopConn, @@ -70,6 +78,7 @@ export const GET = apiHandler(async () => { getIntegrationStatus(user.id, "polar"), getIntegrationStatus(user.id, "oura"), getIntegrationStatus(user.id, "strava"), + getIntegrationStatus(user.id, "nightscout"), prisma.user.findUnique({ where: { id: user.id }, select: { @@ -95,6 +104,11 @@ export const GET = apiHandler(async () => { moodLogEnabled: true, moodLogLastSyncedAt: true, moodLogWebhookSecret: true, + // Nightscout folds onto the envelope; the card reads these three off + // it instead of its own /api/nightscout/status round-trip. + nightscoutUrlEncrypted: true, + nightscoutTokenEncrypted: true, + nightscoutAllowPrivateHost: true, }, }), prisma.withingsConnection.findUnique({ @@ -149,26 +163,93 @@ export const GET = apiHandler(async () => { // ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth // 500-ing the whole Settings card, so a groupBy hiccup degrades to no data. const metricFreshness = await getSourceMetricFreshness(user.id).catch( - () => ({}) as Partial>, + () => ({}) as Partial>, ); const now = Date.now(); const whoopConnected = !!whoopConn?.whoopUserId; + /** + * The sync-health verdict + the per-metric staleness flags, resolved from + * data already in hand — no additional query. Every entry carries it, + * whether or not a card exists for the provider: a card-level verdict misses + * a card-less provider by construction, which is how a month-dead sync went + * unnoticed. + */ + function resolve( + integration: IntegrationKey, + snapshot: { + state: string; + lastSuccessAt: string | null; + lastAttemptAt: string | null; + }, + opts: { + connected?: boolean; + configured?: boolean; + legacyLastSyncedAt?: string | null; + }, + ): { syncHealth: SyncHealth; metricFreshness: MetricFreshnessEntry[] } { + const syncHealth = resolveSyncVerdict({ + connected: opts.connected, + configured: opts.configured, + state: snapshot.state, + lastSuccessAt: snapshot.lastSuccessAt, + lastAttemptAt: snapshot.lastAttemptAt, + legacyLastSyncedAt: opts.legacyLastSyncedAt ?? null, + cadence: INTEGRATION_CADENCE[integration], + now, + }); + return { + syncHealth, + metricFreshness: classifyMetricFreshness( + metricFreshness[integration] ?? [], + syncHealth.verdict, + now, + ), + }; + } + + const withingsConfigured = + !!dbUser?.withingsClientIdEncrypted && + !!dbUser?.withingsClientSecretEncrypted; + const withingsLegacyLastSyncedAt = + withingsConn?.lastSyncedAt?.toISOString() ?? null; + const moodLogConfigured = !!dbUser?.moodLogUrlEncrypted; + const moodLogLegacyLastSyncedAt = + dbUser?.moodLogLastSyncedAt?.toISOString() ?? null; + const whoopConfigured = + !!dbUser?.whoopClientIdEncrypted && !!dbUser?.whoopClientSecretEncrypted; + const whoopLegacyLastSyncedAt = whoopConnected + ? (whoopConn?.lastSyncedAt?.toISOString() ?? null) + : null; + const fitbitConnected = !!fitbitConn; + const fitbitConfigured = + !!dbUser?.fitbitClientIdEncrypted && !!dbUser?.fitbitClientSecretEncrypted; + const fitbitLegacyLastSyncedAt = + fitbitConn?.lastSyncedAt?.toISOString() ?? null; + const googleHealthConnected = !!googleHealthConn; + const googleHealthConfigured = + !!dbUser?.googleHealthClientIdEncrypted && + !!dbUser?.googleHealthClientSecretEncrypted; + const googleHealthLegacyLastSyncedAt = + googleHealthConn?.lastSyncedAt?.toISOString() ?? null; + const polarConnected = !!dbUser?.polarAccessTokenEncrypted; + const ouraConnected = !!dbUser?.ouraAccessTokenEncrypted; + const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted; + const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted; + return apiSuccess({ threshold: getPersistentFailureThreshold(), integrations: [ { ...withingsStatus, - configured: - !!dbUser?.withingsClientIdEncrypted && - !!dbUser?.withingsClientSecretEncrypted, + configured: withingsConfigured, connected: !!withingsConn, connectedAt: withingsConn?.createdAt?.toISOString() ?? null, // Surface the connection row's success time too so the UI // can fall back to it when no IntegrationStatus row exists // yet (legacy connections established before this feature). - legacyLastSyncedAt: withingsConn?.lastSyncedAt?.toISOString() ?? null, + legacyLastSyncedAt: withingsLegacyLastSyncedAt, tokenExpiresAt: withingsConn?.tokenExpiresAt?.toISOString() ?? null, tokenExpired: withingsConn ? withingsConn.tokenExpiresAt.getTime() <= now @@ -178,33 +259,39 @@ export const GET = apiHandler(async () => { // Null `scope` = legacy connection that predates activity-scope reads. scope: withingsConn?.scope ?? null, hasActivityScope: hasActivityScope(withingsConn?.scope ?? null), - metricFreshness: metricFreshness.withings ?? [], + ...resolve("withings", withingsStatus, { + connected: !!withingsConn, + configured: withingsConfigured, + legacyLastSyncedAt: withingsLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & WithingsExtras, { ...moodLogStatus, // moodLog "configured" tracks the URL alone (the API key is // optional for the webhook-only path), matching the legacy // /api/integrations/moodlog/status contract the card relied on. - configured: !!dbUser?.moodLogUrlEncrypted, + configured: moodLogConfigured, enabled: dbUser?.moodLogEnabled ?? false, - legacyLastSyncedAt: dbUser?.moodLogLastSyncedAt?.toISOString() ?? null, + legacyLastSyncedAt: moodLogLegacyLastSyncedAt, // V3 audit STILL-V2-C-2: stored secret is AES-GCM encrypted at rest; // decrypt for the settings page (legacy plaintext is also handled). webhookSecret: readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null), entryCount: moodLogEntryCount, + // moodLog has no connection row — `configured` alone decides whether it + // is meant to be running, so the verdict gets no `connected` hint. + ...resolve("moodlog", moodLogStatus, { + configured: moodLogConfigured, + legacyLastSyncedAt: moodLogLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & MoodLogExtras, { ...whoopStatus, - configured: - !!dbUser?.whoopClientIdEncrypted && - !!dbUser?.whoopClientSecretEncrypted, + configured: whoopConfigured, connected: whoopConnected, connectedAt: whoopConnected ? (whoopConn?.createdAt.toISOString() ?? null) : null, - legacyLastSyncedAt: whoopConnected - ? (whoopConn?.lastSyncedAt?.toISOString() ?? null) - : null, + legacyLastSyncedAt: whoopLegacyLastSyncedAt, tokenExpiresAt: whoopConnected ? (whoopConn?.tokenExpiresAt.toISOString() ?? null) : null, @@ -214,32 +301,35 @@ export const GET = apiHandler(async () => { backfillCompleted: whoopConnected ? !!whoopConn?.backfillCompletedAt : null, - metricFreshness: metricFreshness.whoop ?? [], + ...resolve("whoop", whoopStatus, { + connected: whoopConnected, + configured: whoopConfigured, + legacyLastSyncedAt: whoopLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & WhoopExtras, { ...fitbitStatus, - configured: - !!dbUser?.fitbitClientIdEncrypted && - !!dbUser?.fitbitClientSecretEncrypted, - connected: !!fitbitConn, + configured: fitbitConfigured, + connected: fitbitConnected, connectedAt: fitbitConn?.createdAt?.toISOString() ?? null, - legacyLastSyncedAt: fitbitConn?.lastSyncedAt?.toISOString() ?? null, + legacyLastSyncedAt: fitbitLegacyLastSyncedAt, tokenExpiresAt: fitbitConn?.tokenExpiresAt?.toISOString() ?? null, tokenExpired: fitbitConn ? fitbitConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, - metricFreshness: metricFreshness.fitbit ?? [], + ...resolve("fitbit", fitbitStatus, { + connected: fitbitConnected, + configured: fitbitConfigured, + legacyLastSyncedAt: fitbitLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & FitbitExtras, { ...googleHealthStatus, - configured: - !!dbUser?.googleHealthClientIdEncrypted && - !!dbUser?.googleHealthClientSecretEncrypted, - connected: !!googleHealthConn, + configured: googleHealthConfigured, + connected: googleHealthConnected, connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null, - legacyLastSyncedAt: - googleHealthConn?.lastSyncedAt?.toISOString() ?? null, + legacyLastSyncedAt: googleHealthLegacyLastSyncedAt, tokenExpiresAt: googleHealthConn?.tokenExpiresAt?.toISOString() ?? null, tokenExpired: googleHealthConn ? googleHealthConn.tokenExpiresAt.getTime() <= now @@ -251,7 +341,11 @@ 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, - metricFreshness: metricFreshness["google-health"] ?? [], + ...resolve("google-health", googleHealthStatus, { + connected: googleHealthConnected, + configured: googleHealthConfigured, + legacyLastSyncedAt: googleHealthLegacyLastSyncedAt, + }), } satisfies IntegrationViewModel & GoogleHealthExtras, { ...polarStatus, @@ -259,36 +353,60 @@ export const GET = apiHandler(async () => { // OAuth card has no separate "credentials saved but disconnected" view // beyond `hasOwnCredentials`). The card greys out the connect button // when no usable credentials resolve (`available`). - connected: !!dbUser?.polarAccessTokenEncrypted, - configured: !!dbUser?.polarAccessTokenEncrypted, + connected: polarConnected, + configured: polarConnected, available: polarAvailable, hasOwnCredentials: !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted, - metricFreshness: metricFreshness.polar ?? [], + ...resolve("polar", polarStatus, { + connected: polarConnected, + configured: polarConnected, + }), } satisfies IntegrationViewModel & OAuthProviderExtras, { ...ouraStatus, - connected: !!dbUser?.ouraAccessTokenEncrypted, - configured: !!dbUser?.ouraAccessTokenEncrypted, + connected: ouraConnected, + configured: ouraConnected, available: ouraAvailable, hasOwnCredentials: !!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted, - metricFreshness: metricFreshness.oura ?? [], + ...resolve("oura", ouraStatus, { + connected: ouraConnected, + configured: ouraConnected, + }), } satisfies IntegrationViewModel & OAuthProviderExtras, { ...stravaStatus, - connected: !!dbUser?.stravaAccessTokenEncrypted, - configured: !!dbUser?.stravaAccessTokenEncrypted, + connected: stravaConnected, + configured: stravaConnected, available: stravaAvailable, hasOwnCredentials: !!dbUser?.stravaClientIdEncrypted && !!dbUser?.stravaClientSecretEncrypted, - // Strava writes Workout rows, not Measurements, so it has no - // per-metric measurement freshness (like moodLog). - metricFreshness: metricFreshness.strava ?? [], + // Strava writes Workout rows, not Measurements — its freshness list + // carries the `WORKOUTS` pseudo-entry alone. + ...resolve("strava", stravaStatus, { + connected: stravaConnected, + configured: stravaConnected, + }), } satisfies IntegrationViewModel & OAuthProviderExtras, + { + ...nightscoutStatus, + // Nightscout is a URL the self-hoster pastes once; `configured` (a + // stored URL) is the connect marker, and a fully-public instance has + // no token. The card reads these off the envelope now instead of its + // own status round-trip, so the whole page reads from one source. + connected: nightscoutConfigured, + configured: nightscoutConfigured, + hasToken: !!dbUser?.nightscoutTokenEncrypted, + allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false, + ...resolve("nightscout", nightscoutStatus, { + connected: nightscoutConfigured, + configured: nightscoutConfigured, + }), + } satisfies IntegrationViewModel & NightscoutExtras, ], }); }); @@ -300,12 +418,18 @@ interface IntegrationViewModel { lastAttemptAt: string | null; lastError: string | null; /** - * F-SYNC-1 — per-metric-type last-value timestamps for the integration's - * synced measurements. Present on the measurement-backed integrations; - * omitted for moodLog (which writes MoodEntry rows, not Measurements). Lets - * the card flag a silently-dead metric the green integration state hides. + * The server-resolved liveness verdict. Present on EVERY entry, whether or + * not a card renders it — the unit of coverage is the envelope entry, not + * the card. */ - metricFreshness?: MetricFreshnessEntry[]; + syncHealth: SyncHealth; + /** + * Per-metric-type last-value timestamps for the integration's synced data, + * each with the server-computed `stale` flag. Empty for moodLog (which + * writes MoodEntry rows, not Measurements). Lets the card flag a silently + * dead metric the green integration state hides. + */ + metricFreshness: MetricFreshnessEntry[]; } interface WithingsExtras { @@ -371,3 +495,13 @@ interface OAuthProviderExtras { available: boolean; hasOwnCredentials: boolean; } + +// Nightscout folds onto the envelope so the card stops firing its own status +// round-trip and so the provider inherits the shared verdict. `hasToken` and +// `allowPrivateHost` are the two fields the card's form placeholders read. +interface NightscoutExtras { + connected: boolean; + configured: boolean; + hasToken: boolean; + allowPrivateHost: boolean; +} diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts index d70ac2cc4..43a3ffc4c 100644 --- a/src/lib/integrations/metric-freshness.ts +++ b/src/lib/integrations/metric-freshness.ts @@ -1,5 +1,5 @@ /** - * F-SYNC-1 — per-metric-type freshness (the smallest honest signal). + * Per-metric-type freshness (the smallest honest signal). * * Freshness was tracked only per-integration (`IntegrationStatus.lastSuccessAt` * / a connection's `lastSyncedAt`). A provider that returns HTTP 200 with an @@ -12,19 +12,33 @@ * This computes the last-value timestamp per `(source, type)` straight from the * `Measurement` table (no schema change, no migration) so a caller can surface * per-metric liveness honestly: a metric whose newest reading is frozen weeks in - * the past is visibly distinct from one that is genuinely current. The coarser - * "is this stale for THIS metric's expected cadence" classification (per-type - * thresholds / an expected-cadence probe cron) is the deferred fuller model; - * this exposes the raw honest timestamp the UI renders as "last seen …". + * the past is visibly distinct from one that is genuinely current. The + * classification of "quiet" vs "current" is a pure function next to the verdict + * (`classifyMetricFreshness` in `./sync-verdict`); this module only reads. + * + * Two sources write no `Measurement` rows for part of what they deliver: + * Strava writes only `Workout` rows, and Polar's workout leg is one of its two + * cron legs. A second grouped read over `Workout` appends a `WORKOUTS` + * pseudo-entry so those pipes are visible too. */ import { prisma } from "@/lib/db"; import type { MeasurementSource } from "@/generated/prisma/client"; import type { IntegrationKey } from "./status"; +import { + WORKOUT_FRESHNESS_TYPE, + type MetricFreshnessSample, +} from "./sync-verdict"; + +export type { + MetricFreshnessEntry, + MetricFreshnessSample, +} from "./sync-verdict"; /** * The `MeasurementSource` each sync integration's rows carry. `moodlog` is * absent — it writes `MoodEntry` rows, not `Measurement` rows, so it has no - * per-metric measurement freshness. + * per-metric measurement freshness. `strava` is absent from the measurement + * map for the same reason and picks up its `WORKOUTS` entry below. */ export const INTEGRATION_MEASUREMENT_SOURCE: Partial< Record @@ -38,53 +52,141 @@ export const INTEGRATION_MEASUREMENT_SOURCE: Partial< "google-health": "GOOGLE_HEALTH", }; -/** Newest recorded reading for one `(source, type)` pair. */ -export interface MetricFreshnessEntry { - /** The `MeasurementType` (e.g. "RESPIRATORY_RATE"). */ - type: string; - /** ISO timestamp of the newest (non-deleted) reading for this metric. */ - lastSeenAt: string; +/** + * The `MeasurementSource` each workout-writing integration's `Workout` rows + * carry. Strava is workouts-only; Polar syncs workouts alongside its + * measurements. + */ +export const INTEGRATION_WORKOUT_SOURCE: Partial< + Record +> = { + strava: "STRAVA", + polar: "POLAR", +}; + +/** + * Newest measurement per `(source, type)` for the given sources. One grouped + * query; a `(source, type)` pair with no rows simply has no entry, which is how + * honest absence stays structural — a metric a provider never delivered is + * never invented. + */ +export async function getMeasurementFreshnessBySource( + userId: string, + sources: readonly MeasurementSource[], +): Promise> { + const out = new Map(); + if (sources.length === 0) return out; + + const grouped = await prisma.measurement.groupBy({ + by: ["source", "type"], + where: { userId, deletedAt: null, source: { in: [...sources] } }, + _max: { measuredAt: true }, + }); + + for (const row of grouped) { + const lastSeen = row._max.measuredAt; + if (!lastSeen) continue; + const list = out.get(row.source) ?? []; + list.push({ type: row.type, lastSeenAt: lastSeen.toISOString() }); + out.set(row.source, list); + } + return out; +} + +/** + * Newest workout per source. `Workout` rows are hard-deleted, so there is no + * tombstone predicate to apply. + */ +export async function getWorkoutFreshnessBySource( + userId: string, + sources: readonly MeasurementSource[], +): Promise> { + const out = new Map(); + if (sources.length === 0) return out; + + const grouped = await prisma.workout.groupBy({ + by: ["source"], + where: { userId, source: { in: [...sources] } }, + _max: { startedAt: true }, + }); + + for (const row of grouped) { + const lastSeen = row._max.startedAt; + if (!lastSeen) continue; + out.set(row.source, lastSeen.toISOString()); + } + return out; +} + +/** + * Per-metric last-value timestamps for one measurement source, sorted + * alphabetically for a stable wire shape. Used by the Apple Health route, whose + * transport sits outside the integration envelope. + */ +export async function getSourceFreshness( + userId: string, + source: MeasurementSource, +): Promise { + const [measurements, workouts] = await Promise.all([ + getMeasurementFreshnessBySource(userId, [source]), + getWorkoutFreshnessBySource(userId, [source]), + ]); + const entries = measurements.get(source) ?? []; + const workoutSeenAt = workouts.get(source); + if (workoutSeenAt) { + entries.push({ + type: WORKOUT_FRESHNESS_TYPE, + lastSeenAt: workoutSeenAt, + }); + } + return sortByType(entries); } /** * Compute per-`(integration, type)` last-value timestamps for the sync - * integrations, keyed by `IntegrationKey`. One grouped query over the live - * `Measurement` rows; a source with no rows simply has no entry. Types are - * sorted alphabetically for a stable wire shape. + * integrations, keyed by `IntegrationKey`. Two grouped queries (measurements + + * workouts); a source with no rows simply has no entry. */ export async function getSourceMetricFreshness( userId: string, -): Promise>> { - const sources = Object.values(INTEGRATION_MEASUREMENT_SOURCE); - const grouped = await prisma.measurement.groupBy({ - by: ["source", "type"], - where: { userId, deletedAt: null, source: { in: sources } }, - _max: { measuredAt: true }, - }); +): Promise>> { + const [measurements, workouts] = await Promise.all([ + getMeasurementFreshnessBySource( + userId, + Object.values(INTEGRATION_MEASUREMENT_SOURCE), + ), + getWorkoutFreshnessBySource( + userId, + Object.values(INTEGRATION_WORKOUT_SOURCE), + ), + ]); - // Invert the source→key map once so each grouped row resolves its integration. - const sourceToKey = new Map( - ( - Object.entries(INTEGRATION_MEASUREMENT_SOURCE) as Array< - [IntegrationKey, MeasurementSource] - > - ).map(([key, source]) => [source, key]), - ); + const out: Partial> = {}; - const out: Partial> = {}; - for (const row of grouped) { - const lastSeen = row._max.measuredAt; - if (!lastSeen) continue; - const key = sourceToKey.get(row.source); - if (!key) continue; + for (const [key, source] of Object.entries( + INTEGRATION_MEASUREMENT_SOURCE, + ) as Array<[IntegrationKey, MeasurementSource]>) { + const entries = measurements.get(source); + if (entries?.length) out[key] = entries; + } + + for (const [key, source] of Object.entries( + INTEGRATION_WORKOUT_SOURCE, + ) as Array<[IntegrationKey, MeasurementSource]>) { + const lastSeenAt = workouts.get(source); + if (!lastSeenAt) continue; (out[key] ??= []).push({ - type: row.type, - lastSeenAt: lastSeen.toISOString(), + type: WORKOUT_FRESHNESS_TYPE, + lastSeenAt, }); } for (const key of Object.keys(out) as IntegrationKey[]) { - out[key]!.sort((a, b) => a.type.localeCompare(b.type)); + out[key] = sortByType(out[key]!); } return out; } + +function sortByType(entries: MetricFreshnessSample[]): MetricFreshnessSample[] { + return [...entries].sort((a, b) => a.type.localeCompare(b.type)); +} diff --git a/src/lib/integrations/sync-verdict.ts b/src/lib/integrations/sync-verdict.ts new file mode 100644 index 000000000..81c629125 --- /dev/null +++ b/src/lib/integrations/sync-verdict.ts @@ -0,0 +1,309 @@ +/** + * Server-side sync-health verdict — the one place liveness is decided. + * + * The ledger (`src/lib/integrations/status.ts`) records what the last sync + * attempt DID. It has no way to express what an attempt is no longer doing: + * `error_transient` documents itself as "the sync entry-point still attempts on + * the next run", yet a row can sit at that value for a month while nothing runs + * at all — and nothing anywhere read the AGE of `lastAttemptAt`, so the state + * was unrepresentable. A provider that had stopped even trying looked exactly + * like one that was retrying every hour. + * + * This module derives the missing verdict as a read-side projection. It writes + * nothing, adds no cron, and cannot desynchronise from the row it reads. The + * status envelope computes it per entry from data it already has in hand (zero + * additional queries) and every consumer — the Settings cards, the fallback + * row, the MCP status tool — renders the same answer. + * + * The module is deliberately pure: no Prisma import, no I/O, `import type` only. + * That keeps it importable from client components (the pill mapper reads the + * verdict union) without dragging the database client into the browser bundle. + */ +import type { IntegrationKey } from "./status"; + +/** + * The eight verdicts, from healthiest to least. + * + * fresh → syncing, data is current. + * stale → syncing (or push-fed) but no new data for a while. + * The primary arm for push-based Apple Health. + * stalled → no sync ATTEMPT for longer than the cadence allows. + * Not "failing" — not being tried at all. + * failing → attempting and erroring (transient or persistent). + * reauth_required → the grant is gone; the user must reconnect. + * parked → paused after a persistent failure streak; needs a + * manual resume. + * pending_first_sync → connected/configured but no attempt has been made yet. + * disconnected → no live connection. + */ +export type SyncVerdict = + | "fresh" + | "stale" + | "stalled" + | "failing" + | "reauth_required" + | "parked" + | "pending_first_sync" + | "disconnected"; + +/** The verdict plus the instant that triggered it (null when nothing did). */ +export interface SyncHealth { + verdict: SyncVerdict; + since: string | null; +} + +/** + * How long a polled integration may go without a sync ATTEMPT before it counts + * as stalled. 24 h is roughly 24 missed hourly ticks — unambiguous, and + * tolerant of a maintenance window or a slow redeploy. + */ +export const ATTEMPT_STALE_AFTER_MS = 24 * 60 * 60 * 1000; + +/** + * How long a polled integration may go without new DATA before it counts as + * stale. Near-unreachable for the hourly cohort by construction (every tick + * either stamps a success or flips the row to an error state), so it stands as + * defence rather than as the primary signal. + */ +export const DATA_STALE_AFTER_MS = 48 * 60 * 60 * 1000; + +/** + * Apple Health is push-based: the iOS app delivers, nothing here polls. Seven + * days is the "this pipe is not delivering" line. Shorter windows false-alarm + * on normal iOS background-delivery gaps (low storage, app unopened, budget + * exhausted), and a wrong warning on the flagship integration costs more trust + * than a few days of latency on a genuinely dead one. + */ +export const APPLE_HEALTH_DATA_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * A metric type that has not been seen for this long, while the integration + * around it reads healthy, is the dead-pipe signature. Deliberately generous: + * a weekly weigher must not be warned after a holiday. + */ +export const METRIC_QUIET_AFTER_MS = 14 * 24 * 60 * 60 * 1000; + +/** Per-cohort expectation. `attemptStaleAfterMs: null` = no attempt concept. */ +export interface SyncCadence { + attemptStaleAfterMs: number | null; + dataStaleAfterMs: number; +} + +/** Hourly cron cohort — every integration with a ledger row. */ +export const POLLED_CADENCE: SyncCadence = { + attemptStaleAfterMs: ATTEMPT_STALE_AFTER_MS, + dataStaleAfterMs: DATA_STALE_AFTER_MS, +}; + +/** Apple Health — the iOS app pushes; there is nothing to "attempt". */ +export const PUSH_CADENCE: SyncCadence = { + attemptStaleAfterMs: null, + dataStaleAfterMs: APPLE_HEALTH_DATA_STALE_AFTER_MS, +}; + +/** + * Every `IntegrationKey` is polled by an hourly cron registered in + * `src/lib/jobs/reminder/register-integration-sync.ts`. Apple Health is not an + * `IntegrationKey` — it takes `PUSH_CADENCE` directly. + */ +export const INTEGRATION_CADENCE: Record = { + withings: POLLED_CADENCE, + whoop: POLLED_CADENCE, + fitbit: POLLED_CADENCE, + nightscout: POLLED_CADENCE, + polar: POLLED_CADENCE, + oura: POLLED_CADENCE, + "google-health": POLLED_CADENCE, + strava: POLLED_CADENCE, + moodlog: POLLED_CADENCE, +}; + +export interface SyncVerdictInput { + /** + * Whether a live connection exists (an OAuth token, a connection row). Pass + * `undefined` for providers that carry no connection concept — moodLog is + * configured by URL alone — so the ladder falls through to `configured`. + */ + connected?: boolean; + /** Whether credentials / a URL are stored for this provider. */ + configured?: boolean; + /** The ledger `state` string. */ + state?: string | null; + lastSuccessAt?: string | Date | null; + lastAttemptAt?: string | Date | null; + /** A connection row's own `lastSyncedAt`, for rows predating the ledger. */ + legacyLastSyncedAt?: string | Date | null; + /** Newest observed data instant, when the caller knows it (Apple Health). */ + lastDataAt?: string | Date | null; + cadence: SyncCadence; + now?: Date | number; +} + +function toMillis(value: string | Date | null | undefined): number | null { + if (value == null) return null; + const ms = value instanceof Date ? value.getTime() : Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +/** Newest of the supplied instants, or null when they are all absent. */ +function newest(...values: Array): number | null { + let max: number | null = null; + for (const value of values) { + if (value == null) continue; + if (max == null || value > max) max = value; + } + return max; +} + +function iso(ms: number | null): string | null { + return ms == null ? null : new Date(ms).toISOString(); +} + +/** + * Resolve the sync-health verdict. First match wins. + * + * The `stalled` arm deliberately outranks `failing`: a row frozen at + * `error_transient` for a month is not failing, it is not being tried — the + * exact contradiction of the state's own documented contract. + * + * Two arms are refinements over the pure-ledger ladder, and they preserve what + * the cards already rendered: an explicit `connected === false` and an explicit + * `state === "disconnected"` both short-circuit to `disconnected`. Without them + * a provider whose credentials are still stored after a disconnect would age + * into `stalled` and claim its sync had stopped, when the user stopped it. + */ +export function resolveSyncVerdict(input: SyncVerdictInput): SyncHealth { + const nowMs = + input.now == null + ? Date.now() + : input.now instanceof Date + ? input.now.getTime() + : input.now; + + const lastSuccess = toMillis(input.lastSuccessAt); + const lastAttempt = toMillis(input.lastAttemptAt); + const legacy = toMillis(input.legacyLastSyncedAt); + const lastData = toMillis(input.lastDataAt); + + // 1. No live connection, or one the user explicitly ended. + if (input.connected === false) + return { verdict: "disconnected", since: null }; + if (!input.connected && !input.configured) { + return { verdict: "disconnected", since: null }; + } + if (input.state === "disconnected") { + return { verdict: "disconnected", since: null }; + } + + // 2. States the user has to act on, in order of specificity. + if (input.state === "parked") { + return { verdict: "parked", since: iso(lastAttempt) }; + } + if (input.state === "error_reauth") { + return { verdict: "reauth_required", since: iso(lastAttempt) }; + } + + // 3. Nothing has ever run and nothing has ever arrived — the honest reading + // of the "connected, never attempted" synthetic ledger snapshot, and of a + // push source that has yet to deliver its first sample. + if (newest(lastAttempt, lastSuccess, legacy, lastData) == null) { + return { verdict: "pending_first_sync", since: null }; + } + + // 4. Polled cohort only: has it even tried lately? + const attemptWindow = input.cadence.attemptStaleAfterMs; + if (attemptWindow != null) { + const lastTouch = newest(lastAttempt, lastSuccess); + if (lastTouch != null && nowMs - lastTouch > attemptWindow) { + return { verdict: "stalled", since: iso(lastTouch) }; + } + } + + // 5. Trying and erroring. + if (input.state === "error_transient") { + return { verdict: "failing", since: iso(lastSuccess) }; + } + + // 6. Running, but nothing new is arriving. The primary arm for push sources. + const lastFresh = newest(lastSuccess, legacy, lastData); + if (lastFresh != null && nowMs - lastFresh > input.cadence.dataStaleAfterMs) { + return { verdict: "stale", since: iso(lastFresh) }; + } + + return { verdict: "fresh", since: null }; +} + +/** + * Metric types that are irregular by nature. A fixed quiet-window would + * false-positive on every one of them, so they render their honest timestamp + * and never tint. Starting list, tunable — the fuller per-type cadence model + * stays the deferred follow-up. + */ +export const SPARSE_METRIC_TYPES: ReadonlySet = new Set([ + // Measured only when the user steps on a body-composition scale that + // supports them, or during an occasional fitness test. + "VO2_MAX", + "VASCULAR_AGE", + "PULSE_WAVE_VELOCITY", + "SIX_MINUTE_WALK_DISTANCE", + "FALL_COUNT", + // Event types: fired by the device only when something happens. Silence is + // the healthy reading. + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", + "AUDIO_EXPOSURE_EVENT", + // People train irregularly; a quiet training week is not a broken pipe. + "WORKOUTS", +]); + +/** The pseudo-type carrying a source's newest workout, next to its metrics. */ +export const WORKOUT_FRESHNESS_TYPE = "WORKOUTS"; + +/** Newest recorded reading for one `(source, type)` pair, as queried. */ +export interface MetricFreshnessSample { + /** The `MeasurementType` (e.g. "RESPIRATORY_RATE"), or `WORKOUTS`. */ + type: string; + /** ISO timestamp of the newest (non-deleted) reading for this metric. */ + lastSeenAt: string; +} + +/** A sample plus the server's staleness verdict for it. */ +export interface MetricFreshnessEntry extends MetricFreshnessSample { + /** + * The metric has gone quiet while the integration around it reads healthy. + * Only ever true when the provider verdict is `fresh` — see + * `classifyMetricFreshness`. + */ + stale: boolean; +} + +/** + * Attach the per-metric staleness flag. + * + * A metric silent for weeks WHILE the pipe is otherwise green is the dead-pipe + * signature: a per-collection 403, an upstream envelope drift, a revoked + * per-type permission. When the provider itself is stalled / failing / stale + * every metric ages together, so tinting them would only restate what the pill + * already says — the entries stay neutral and the provider verdict carries the + * story. + */ +export function classifyMetricFreshness( + samples: readonly MetricFreshnessSample[], + verdict: SyncVerdict, + now: Date | number = Date.now(), +): MetricFreshnessEntry[] { + const nowMs = now instanceof Date ? now.getTime() : now; + return samples.map((sample) => { + if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) { + return { ...sample, stale: false }; + } + const seen = toMillis(sample.lastSeenAt); + return { + ...sample, + stale: seen != null && nowMs - seen > METRIC_QUIET_AFTER_MS, + }; + }); +} diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 729556d82..d84ace1ac 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -62,6 +62,10 @@ import { getIntegrationStatus, type IntegrationKey, } from "@/lib/integrations/status"; +import { + INTEGRATION_CADENCE, + resolveSyncVerdict, +} from "@/lib/integrations/sync-verdict"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence"; import type { McpAuthContext } from "./auth"; @@ -860,6 +864,8 @@ const getIntegrationStatusOutput: z.ZodRawShape = { reauthRequired: z.boolean(), lastSuccessAt: z.string().nullable(), lastAttemptAt: z.string().nullable(), + verdict: z.string(), + since: z.string().nullable(), }), ) .optional(), @@ -1478,7 +1484,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ name: "get_integration_status", title: "Get device & service sync status", description: - "Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, moodLog) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. Returns { present: false } when no integration has ever attempted a sync.", + "Fetch the sync health of the user's connected devices and services (Withings, WHOOP, Fitbit, Nightscout, Polar, Oura, moodLog) — which are connected, when each last synced, and whether one needs reconnecting or is failing. Answers 'why is my data stale?'. Reuses the integration-status ledger; carries no secrets or tokens. `connected` means only 'not explicitly disconnected'; `verdict` is the liveness truth (fresh / stale / stalled / failing / reauth_required / parked / pending_first_sync / disconnected), where `stalled` means the sync has stopped even attempting. Returns { present: false } when no integration has ever attempted a sync.", inputShape: {}, annotations: READ_ONLY_ANNOTATIONS, outputShape: getIntegrationStatusOutput, @@ -1503,6 +1509,17 @@ export const MCP_TOOLS: McpToolDefinition[] = [ ctx.userId, r.integration as IntegrationKey, ); + // `connected` means "not explicitly disconnected" and says nothing + // about liveness — a month-dead pull still reads true. `verdict` is + // the liveness truth, resolved by the same server-side rule the + // Settings page renders. + const health = resolveSyncVerdict({ + configured: true, + state: s.state, + lastSuccessAt: s.lastSuccessAt, + lastAttemptAt: s.lastAttemptAt, + cadence: INTEGRATION_CADENCE[s.integration], + }); return { provider: s.integration, state: s.state, @@ -1510,6 +1527,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [ reauthRequired: s.state === "error_reauth" || s.state === "parked", lastSuccessAt: s.lastSuccessAt, lastAttemptAt: s.lastAttemptAt, + verdict: health.verdict, + since: health.since, }; }), ); diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index df9273617..df79d5520 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -29,6 +29,57 @@ const healthKitEntry = z "One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).", }); +// Mirrors `src/lib/integrations/sync-verdict.ts` — the server-resolved liveness +// verdict. Apple Health is push-based, so only the data-age arms apply. +const syncHealth = z + .object({ + verdict: z + .enum([ + "fresh", + "stale", + "stalled", + "failing", + "reauth_required", + "parked", + "pending_first_sync", + "disconnected", + ]) + .describe( + "Liveness verdict. For Apple Health: `fresh` when data arrived within the last 7 days, `stale` when it did not, `pending_first_sync` when none ever arrived.", + ), + since: z.iso + .datetime({ offset: true }) + .nullable() + .describe("The instant that triggered the verdict; null when none did."), + }) + .meta({ + id: "SyncHealth", + description: + "Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'.", + }); + +const metricFreshnessEntry = z + .object({ + type: z + .string() + .describe( + "The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.", + ), + lastSeenAt: z.iso + .datetime({ offset: true }) + .describe("Newest recorded reading for this type from this source."), + stale: z + .boolean() + .describe( + "This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`.", + ), + }) + .meta({ + id: "MetricFreshnessEntry", + description: + "Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row.", + }); + const healthKitConfigResponse = z .object({ entries: z.array(healthKitEntry), @@ -38,11 +89,20 @@ const healthKitConfigResponse = z .describe( "When HealthKit last synced for this user; null when never (and always null on the PATCH echo).", ), + syncHealth: syncHealth + .optional() + .describe("Present on the GET read; omitted from the PATCH echo."), + metricFreshness: z + .array(metricFreshnessEntry) + .optional() + .describe( + "Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.", + ), }) .meta({ id: "HealthKitConfigResponse", description: - "The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides.", + "The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides, plus the sync-health verdict and per-metric freshness.", }); // Mirror the route's `patchSchema` — merge-by-id; unknown ids are ignored.