From de47bfa5ba1bc82f916066e562bd45da01fd2db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 09:41:20 +0200 Subject: [PATCH 1/4] feat(integrations): resolve sync health on the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ledger records what the last attempt did. It has no way to say what is no longer being attempted: a row frozen at `error_transient` for a month reads exactly like one retrying every hour, because nothing anywhere read the age of `lastAttemptAt`. `sync-verdict.ts` derives the missing reading as a pure read-side projection: eight verdicts down one ladder, no writer, no cron, nothing that can desynchronise from the row it reads. `stalled` is the new one, and it deliberately outranks `failing` — an integration nobody is calling is not failing, it is not running. Every entry on `/api/integrations/status` carries the verdict, computed inline from data the route already holds. That placement is the point: a card-level verdict misses a card-less provider by construction, which is how a dead sync stayed invisible. Nightscout joins the envelope so its card can stop fetching its own status. The freshness module gains the workout leg (Strava writes no measurements at all) and a source-parameterised core so the Apple Health route can reuse it. Its per-metric `stale` flag fires only while the provider itself reads healthy: that combination is the dead-pipe signature, and when the whole connection has stopped every metric ages together anyway. MCP reports the verdict beside `connected`, which only ever meant "not explicitly disconnected". --- src/app/api/integrations/healthkit/route.ts | 28 +- src/app/api/integrations/status/route.ts | 222 +++++++++++--- src/lib/integrations/metric-freshness.ts | 180 +++++++++--- src/lib/integrations/sync-verdict.ts | 309 ++++++++++++++++++++ src/lib/mcp/tools.ts | 21 +- src/lib/openapi/routes/integrations.ts | 62 +++- 6 files changed, 736 insertions(+), 86 deletions(-) create mode 100644 src/lib/integrations/sync-verdict.ts 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. From ce74fd52a22b8ab9cc4c37fd4526ba0e629d8ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 09:41:37 +0200 Subject: [PATCH 2/4] feat(settings): render the sync verdict and per-metric freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three `pillStateFor` implementations disagreed with each other, so the same ledger state painted red on one card and amber on the next, on one page. All three are gone; the client projects the server verdict once. Red now means "your action fixes this" and nothing else. A transient upstream failure carries the warning tone: telling a user to reconnect against a 503 sends them clicking at something they cannot repair, and the detail belongs in the inline error line anyway. Three new pill states carry the rest — no recent data, sync stopped, waiting for first data. Per-metric freshness has been computed on every status call since v1.27.30 and rendered nowhere. Every connected card now opens a disclosure listing each data type the connection has delivered and how long ago, with a quiet one flagged. Collapsed by default so a 25-type card keeps its shape, but the collapsed summary already names the count, or the signal hides behind a click. A type a provider never delivered simply is not listed. The panel iterates the envelope and renders a plain status row for every configured entry no card claims. That is the structural half: a connection can no longer be running, failing and absent from the page at once. Apple Health drops its bespoke badge for the shared pill, gains the divider and body inset every sibling carries, and says something when a week has passed with nothing arriving. Nightscout reads the envelope instead of its own status round-trip. The OAuth card uses the shared error primitive, all nine cards carry the divider testid, and the four original connectors gain the "view your data" link the newer ones already had. --- messages/de.json | 29 +++- messages/en.json | 29 +++- messages/es.json | 29 +++- messages/fr.json | 29 +++- messages/it.json | 29 +++- messages/pl.json | 29 +++- .../settings/integration-status-pill.tsx | 89 +++++++--- .../integrations/apple-health-card.tsx | 157 ++++++++++-------- .../integrations/connections-panel.tsx | 49 +++++- .../settings/integrations/fitbit-card.tsx | 38 ++++- .../integrations/google-health-card.tsx | 47 +++++- .../integrations/integration-fallback-row.tsx | 100 +++++++++++ .../metric-freshness-disclosure.tsx | 154 +++++++++++++++++ .../settings/integrations/nightscout-card.tsx | 108 +++++------- .../integrations/oauth-provider-card.tsx | 101 ++++++----- .../settings/integrations/oura-card.tsx | 9 +- .../settings/integrations/polar-card.tsx | 9 +- .../settings/integrations/shared.tsx | 96 +++++++---- .../settings/integrations/strava-card.tsx | 9 +- .../settings/integrations/whoop-card.tsx | 40 ++++- .../settings/integrations/withings-card.tsx | 48 +++++- src/lib/query-keys/integrations.ts | 7 +- 22 files changed, 887 insertions(+), 348 deletions(-) create mode 100644 src/components/settings/integrations/integration-fallback-row.tsx create mode 100644 src/components/settings/integrations/metric-freshness-disclosure.tsx diff --git a/messages/de.json b/messages/de.json index b7b6373a8..7bde66b09 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5279,7 +5279,7 @@ "integrationPill": { "connected": "Verbunden", "errorReconnect": "Fehler — neu verbinden", - "warningServerError": "Verbunden, aber Serverfehler", + "warningServerError": "Verbunden, Sync-Problem", "parkedReconnect": "Pausiert — manuell wieder verbinden", "notConnected": "Nicht verbunden", "justNow": "gerade eben", @@ -5289,8 +5289,26 @@ "ariaLabel": "Integrationsstatus", "resumeCta": "Wieder verbinden", "resumeSuccess": "Integration wieder aktiv", - "resumeError": "Wieder verbinden fehlgeschlagen" + "resumeError": "Wieder verbinden fehlgeschlagen", + "stale": "Verbunden, keine neuen Daten", + "stalled": "Sync gestoppt", + "pendingSetup": "Warte auf erste Daten" }, + "integrationFreshness": { + "title": "Empfangene Daten", + "summary": "{count} Datentypen · neuestes {relative}", + "quiet": "{count} still", + "workouts": "Workouts" + }, + "integrationFallback": { + "description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte. Diese Zeile sorgt dafür, dass eine Verbindung nicht unbemerkt ausfallen kann.", + "lastAttempt": "Letzter Versuch {relative}", + "noAttempt": "Kein Sync-Versuch aufgezeichnet" + }, + "withingsViewData": "Messwerte ansehen", + "whoopViewData": "Erholung ansehen", + "fitbitViewData": "Schlaf ansehen", + "googleHealthViewData": "Schritte ansehen", "apiTokens": "API-Tokens", "apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.", "tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.", @@ -5850,17 +5868,14 @@ "title": "Apple Health", "description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App – nicht über eine Web- oder OAuth-Verbindung.", "status": { - "checking": "Apple-Health-Daten werden geprüft…", - "dataReceived": "Apple-Health-Daten empfangen", - "setup": "In der iOS-App einrichten", "unavailable": "Apple-Health-Status nicht verfügbar" }, - "lastDataLabel": "Letzte Apple-Health-Daten:", "setupTitle": "Live-Synchronisierung aktivieren", "permissionStep": "Öffne in der HealthLog-iOS-App Einstellungen → Apple Health und erlaube die Gesundheitskategorien, die du teilen möchtest.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/en.json b/messages/en.json index eb739414e..e6b4d44d8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5269,7 +5269,7 @@ "integrationPill": { "connected": "Connected", "errorReconnect": "Error — reconnect", - "warningServerError": "Connected, server error", + "warningServerError": "Connected, sync issue", "parkedReconnect": "Paused — reconnect manually", "notConnected": "Not connected", "justNow": "just now", @@ -5279,8 +5279,26 @@ "ariaLabel": "Integration status", "resumeCta": "Reconnect", "resumeSuccess": "Integration resumed", - "resumeError": "Reconnect failed" + "resumeError": "Reconnect failed", + "stale": "Connected, no recent data", + "stalled": "Sync stopped", + "pendingSetup": "Waiting for first data" }, + "integrationFreshness": { + "title": "Data received", + "summary": "{count} data types · newest {relative}", + "quiet": "{count} quiet", + "workouts": "Workouts" + }, + "integrationFallback": { + "description": "Configured on this account without a settings card of its own. This row exists so a connection cannot stop working unnoticed.", + "lastAttempt": "Last attempt {relative}", + "noAttempt": "No sync attempt recorded" + }, + "withingsViewData": "View your measurements", + "whoopViewData": "View your recovery", + "fitbitViewData": "View your sleep", + "googleHealthViewData": "View your steps", "apiTokens": "API Tokens", "apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.", "tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.", @@ -5840,17 +5858,14 @@ "title": "Apple Health", "description": "Live sync comes from the HealthLog iOS app. It is not a web or OAuth connection.", "status": { - "checking": "Checking Apple Health data…", - "dataReceived": "Apple Health data received", - "setup": "Set up in iOS app", "unavailable": "Apple Health status unavailable" }, - "lastDataLabel": "Last Apple Health data:", "setupTitle": "Turn on live sync", "permissionStep": "In the HealthLog iOS app, open Settings → Apple Health and allow the health categories you want to share.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/es.json b/messages/es.json index 5799a20cf..75b5da539 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5281,7 +5281,7 @@ "integrationPill": { "connected": "Conectado", "errorReconnect": "Error — reconectar", - "warningServerError": "Conectado, error del servidor", + "warningServerError": "Conectado, problema de sincronización", "parkedReconnect": "Pausado — reconectar manualmente", "notConnected": "No conectado", "justNow": "ahora mismo", @@ -5291,8 +5291,26 @@ "ariaLabel": "Estado de la integración", "resumeCta": "Reconectar", "resumeSuccess": "Integración reanudada", - "resumeError": "Reconexión fallida" + "resumeError": "Reconexión fallida", + "stale": "Conectado, sin datos recientes", + "stalled": "Sincronización detenida", + "pendingSetup": "Esperando los primeros datos" }, + "integrationFreshness": { + "title": "Datos recibidos", + "summary": "{count} tipos de datos · más reciente {relative}", + "quiet": "{count} en silencio", + "workouts": "Entrenamientos" + }, + "integrationFallback": { + "description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia. Esta fila existe para que una conexión no deje de funcionar sin que nadie lo note.", + "lastAttempt": "Último intento {relative}", + "noAttempt": "Ningún intento de sincronización registrado" + }, + "withingsViewData": "Ver tus mediciones", + "whoopViewData": "Ver tu recuperación", + "fitbitViewData": "Ver tu sueño", + "googleHealthViewData": "Ver tus pasos", "apiTokens": "Tokens de API", "apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.", "tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.", @@ -5852,17 +5870,14 @@ "title": "Apple Health", "description": "La sincronización en vivo se realiza desde la app HealthLog para iOS, no mediante una conexión web ni OAuth.", "status": { - "checking": "Comprobando datos de Apple Health…", - "dataReceived": "Datos de Apple Health recibidos", - "setup": "Configurar en la app de iOS", "unavailable": "Estado de Apple Health no disponible" }, - "lastDataLabel": "Últimos datos de Apple Health:", "setupTitle": "Activar la sincronización en vivo", "permissionStep": "En la app HealthLog para iOS, abre Ajustes → Apple Health y permite las categorías de salud que quieras compartir.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/fr.json b/messages/fr.json index e2c6159af..a0a638b3d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5283,7 +5283,7 @@ "integrationPill": { "connected": "Connecté", "errorReconnect": "Erreur — reconnecter", - "warningServerError": "Connecté, erreur serveur", + "warningServerError": "Connecté, problème de synchronisation", "parkedReconnect": "En pause — reconnecter manuellement", "notConnected": "Non connecté", "justNow": "à l’instant", @@ -5293,8 +5293,26 @@ "ariaLabel": "Statut de l’intégration", "resumeCta": "Reconnecter", "resumeSuccess": "Intégration reprise", - "resumeError": "Échec de la reconnexion" + "resumeError": "Échec de la reconnexion", + "stale": "Connecté, aucune donnée récente", + "stalled": "Synchronisation arrêtée", + "pendingSetup": "En attente des premières données" }, + "integrationFreshness": { + "title": "Données reçues", + "summary": "{count} types de données · plus récent {relative}", + "quiet": "{count} silencieux", + "workouts": "Séances" + }, + "integrationFallback": { + "description": "Configuré sur ce compte sans carte de réglages dédiée. Cette ligne existe pour qu'une connexion ne puisse pas cesser de fonctionner sans que personne ne le voie.", + "lastAttempt": "Dernière tentative {relative}", + "noAttempt": "Aucune tentative de synchronisation enregistrée" + }, + "withingsViewData": "Voir vos mesures", + "whoopViewData": "Voir votre récupération", + "fitbitViewData": "Voir votre sommeil", + "googleHealthViewData": "Voir vos pas", "apiTokens": "Jetons API", "apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.", "tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.", @@ -5854,17 +5872,14 @@ "title": "Apple Health", "description": "La synchronisation en direct passe par l’app HealthLog pour iOS, et non par une connexion web ou OAuth.", "status": { - "checking": "Vérification des données Apple Health…", - "dataReceived": "Données Apple Health reçues", - "setup": "Configurer dans l’app iOS", "unavailable": "Statut Apple Health indisponible" }, - "lastDataLabel": "Dernières données Apple Health :", "setupTitle": "Activer la synchronisation en direct", "permissionStep": "Dans l’app HealthLog pour iOS, ouvre Réglages → Apple Health et autorise les catégories de santé que tu souhaites partager.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/it.json b/messages/it.json index ada7a6ab9..114b1fc07 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5273,7 +5273,7 @@ "integrationPill": { "connected": "Connesso", "errorReconnect": "Errore — riconnetti", - "warningServerError": "Connesso, errore del server", + "warningServerError": "Connesso, problema di sincronizzazione", "parkedReconnect": "In pausa — riconnettere manualmente", "notConnected": "Non connesso", "justNow": "proprio ora", @@ -5283,8 +5283,26 @@ "ariaLabel": "Stato dell’integrazione", "resumeCta": "Riconnetti", "resumeSuccess": "Integrazione ripresa", - "resumeError": "Riconnessione fallita" + "resumeError": "Riconnessione fallita", + "stale": "Connesso, nessun dato recente", + "stalled": "Sincronizzazione ferma", + "pendingSetup": "In attesa dei primi dati" }, + "integrationFreshness": { + "title": "Dati ricevuti", + "summary": "{count} tipi di dati · più recente {relative}", + "quiet": "{count} in silenzio", + "workouts": "Allenamenti" + }, + "integrationFallback": { + "description": "Configurato su questo account ma senza una scheda di impostazioni propria. Questa riga esiste perché una connessione non possa smettere di funzionare senza che nessuno se ne accorga.", + "lastAttempt": "Ultimo tentativo {relative}", + "noAttempt": "Nessun tentativo di sincronizzazione registrato" + }, + "withingsViewData": "Vedi le tue misurazioni", + "whoopViewData": "Vedi il tuo recupero", + "fitbitViewData": "Vedi il tuo sonno", + "googleHealthViewData": "Vedi i tuoi passi", "apiTokens": "Token API", "apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.", "tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.", @@ -5844,17 +5862,14 @@ "title": "Apple Health", "description": "La sincronizzazione in tempo reale avviene tramite l’app HealthLog per iOS, non tramite una connessione web o OAuth.", "status": { - "checking": "Controllo dei dati Apple Health…", - "dataReceived": "Dati Apple Health ricevuti", - "setup": "Configura nell’app iOS", "unavailable": "Stato di Apple Health non disponibile" }, - "lastDataLabel": "Ultimi dati Apple Health:", "setupTitle": "Attiva la sincronizzazione in tempo reale", "permissionStep": "Nell’app HealthLog per iOS, apri Impostazioni → Apple Health e autorizza le categorie di salute che vuoi condividere.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/messages/pl.json b/messages/pl.json index 309fb99b2..85bcb9a2f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5267,7 +5267,7 @@ "integrationPill": { "connected": "Połączono", "errorReconnect": "Błąd — połącz ponownie", - "warningServerError": "Połączono, błąd serwera", + "warningServerError": "Połączono, problem z synchronizacją", "parkedReconnect": "Wstrzymane — połącz ręcznie", "notConnected": "Nie połączono", "justNow": "przed chwilą", @@ -5277,8 +5277,26 @@ "ariaLabel": "Status integracji", "resumeCta": "Połącz ponownie", "resumeSuccess": "Integracja wznowiona", - "resumeError": "Ponowne połączenie nieudane" + "resumeError": "Ponowne połączenie nieudane", + "stale": "Połączono, brak nowych danych", + "stalled": "Synchronizacja zatrzymana", + "pendingSetup": "Czekam na pierwsze dane" }, + "integrationFreshness": { + "title": "Odebrane dane", + "summary": "{count} typów danych · najnowsze {relative}", + "quiet": "{count} bez danych", + "workouts": "Treningi" + }, + "integrationFallback": { + "description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień. Ten wiersz istnieje po to, by przerwane połączenie nie pozostało niezauważone.", + "lastAttempt": "Ostatnia próba {relative}", + "noAttempt": "Brak zapisanej próby synchronizacji" + }, + "withingsViewData": "Zobacz swoje pomiary", + "whoopViewData": "Zobacz swoją regenerację", + "fitbitViewData": "Zobacz swój sen", + "googleHealthViewData": "Zobacz swoje kroki", "apiTokens": "Tokeny API", "apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.", "tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.", @@ -5838,17 +5856,14 @@ "title": "Apple Health", "description": "Synchronizacja na żywo odbywa się przez aplikację HealthLog na iOS, a nie przez połączenie internetowe ani OAuth.", "status": { - "checking": "Sprawdzanie danych Apple Health…", - "dataReceived": "Odebrano dane Apple Health", - "setup": "Skonfiguruj w aplikacji iOS", "unavailable": "Stan Apple Health jest niedostępny" }, - "lastDataLabel": "Najnowsze dane Apple Health:", "setupTitle": "Włącz synchronizację na żywo", "permissionStep": "W aplikacji HealthLog na iOS otwórz Ustawienia → Apple Health i zezwól na udostępnianie wybranych kategorii zdrowotnych.", "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" + "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." }, "garminInfo": { "title": "Garmin", diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx index b65c4adb9..35d8ef14d 100644 --- a/src/components/settings/integration-status-pill.tsx +++ b/src/components/settings/integration-status-pill.tsx @@ -4,6 +4,8 @@ import { CheckCircle2, AlertTriangle, CircleSlash, + Clock, + Info, PauseCircle, } from "lucide-react"; @@ -32,20 +34,33 @@ import { cn } from "@/lib/utils"; */ /** - * Pill states. + * Pill states — a thin projection of the server's sync-health verdict. * * - connected → happy path; relative "last sync" suffix is shown. - * - warning → connected but the upstream just returned a - * contract-mismatch error (v1.4.43 W4). - * - error → reauth-required / persistent transient error; - * "Error — reconnect" copy. - * - parked → v1.4.43 W14: >24h of persistent failures put the - * integration to sleep; "Pausiert — manuell wieder - * verbinden" copy + reconnect CTA. + * - stale → connected, but no new data for a while; suffix shown. + * - stalled → the sync stopped even attempting; suffix shown. + * - warning → attempting and failing (a 5xx, a rate limit, an upstream + * contract mismatch). Not the user's to fix. + * - error → reauth-required; "Error — reconnect" copy. + * - parked → >24h of persistent failures put the integration to + * sleep; "Paused — reconnect manually" copy + resume CTA. + * - pending-setup → configured but nothing has arrived yet. * - disconnected → user clicked Disconnect; "Not connected" copy. + * + * **Red is reserved for "your action fixes this".** A transient upstream + * failure carries the warning tone, not the destructive one: telling a user to + * reconnect cannot help against a 503, and ten fruitless reconnect clicks are + * the cost of saying otherwise. */ export type IntegrationPillState = - "connected" | "warning" | "error" | "parked" | "disconnected"; + | "connected" + | "stale" + | "stalled" + | "warning" + | "error" + | "parked" + | "pending-setup" + | "disconnected"; interface IntegrationStatusPillProps { state: IntegrationPillState; @@ -56,6 +71,8 @@ interface IntegrationStatusPillProps { lastSyncAt: Date | string | null; /** Override "now" for deterministic testing. Defaults to `new Date()`. */ now?: Date; + /** Override the testid so a card can keep its own established e2e hook. */ + testId?: string; className?: string; } @@ -68,7 +85,7 @@ function toDate(value: Date | string): Date { * string. We keep the buckets small and fixed (just-now / minutes / * hours / days) because the pill must fit on a 360 px wide card. */ -function formatRelative( +export function formatRelative( deltaMs: number, t: (key: string, params?: Record) => string, ): string { @@ -85,26 +102,37 @@ function formatRelative( return t("settings.integrationPill.daysAgo", { count: days }); } +/** The states that paint the semantic warning chip rather than a variant. */ +const WARNING_STATES = new Set([ + "stale", + "stalled", + "warning", + "parked", +]); + +/** The states whose label carries an inline relative timestamp. */ +const RELATIVE_STATES = new Set([ + "connected", + "stale", + "stalled", +]); + export function IntegrationStatusPill({ state, lastSyncAt, now, + testId, className, }: IntegrationStatusPillProps) { const { t } = useTranslations(); - // chipClass only applies to the `connected`, `warning`, and `parked` - // branches — `error` falls back to `variant="destructive"` and - // `disconnected` to `variant="outline"`. The warning and parked chips - // both carry the semantic `warning` tone (an AA-safe amber that clears - // contrast in light mode); their distinct labels + icons carry the - // meaning — "connected, but the upstream returned a contract-level error - // a reauth won't fix" vs "tried to keep going for 24h but the mismatch - // persists, manual intervention required" — distinct from the red - // reconnect pill (`error`). + // The chip classes apply to `connected` and to every warning-tone state — + // `error` falls back to `variant="destructive"`, `disconnected` and + // `pending-setup` to `variant="outline"`. The warning tone is an AA-safe + // amber that clears contrast in light mode; the distinct labels + icons + // carry which flavour of "not right" this is. const connectedChipClass = "border-success/30 bg-success/15 text-success"; const warningChipClass = "border-warning/30 bg-warning/15 text-warning"; - const parkedChipClass = "border-warning/30 bg-warning/15 text-warning"; let label: string; let icon: React.ReactNode; @@ -114,6 +142,14 @@ export function IntegrationStatusPill({ label = t("settings.integrationPill.connected"); icon =