diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 1ed88a25a..dfc84c48e 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -24,6 +24,10 @@ import { getPersistentFailureThreshold, type IntegrationKey, } from "@/lib/integrations/status"; +import { + getSourceMetricFreshness, + type MetricFreshnessEntry, +} from "@/lib/integrations/metric-freshness"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { hasActivityScope } from "@/lib/withings/client"; @@ -131,6 +135,14 @@ export const GET = apiHandler(async () => { getOuraClientCredentials(user.id).then((c) => !!c), ]); + // F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a + // single metric type has gone silent even while the integration reads green + // ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth + // 500-ing the whole Settings card, so a groupBy hiccup degrades to no data. + const metricFreshness = await getSourceMetricFreshness(user.id).catch( + () => ({}) as Partial>, + ); + const now = Date.now(); return apiSuccess({ @@ -156,6 +168,7 @@ 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 ?? [], } satisfies IntegrationViewModel & WithingsExtras, { ...moodLogStatus, @@ -183,6 +196,7 @@ export const GET = apiHandler(async () => { ? whoopConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: whoopConn ? !!whoopConn.backfillCompletedAt : null, + metricFreshness: metricFreshness.whoop ?? [], } satisfies IntegrationViewModel & WhoopExtras, { ...fitbitStatus, @@ -197,6 +211,7 @@ export const GET = apiHandler(async () => { ? fitbitConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, + metricFreshness: metricFreshness.fitbit ?? [], } satisfies IntegrationViewModel & FitbitExtras, { ...googleHealthStatus, @@ -218,6 +233,7 @@ export const GET = apiHandler(async () => { // user-revoked grant) surfaced from the connection row; the card reads it // to raise a distinct "Reconnect" CTA separate from parked/disconnected. needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false, + metricFreshness: metricFreshness["google-health"] ?? [], } satisfies IntegrationViewModel & GoogleHealthExtras, { ...polarStatus, @@ -231,6 +247,7 @@ export const GET = apiHandler(async () => { hasOwnCredentials: !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted, + metricFreshness: metricFreshness.polar ?? [], } satisfies IntegrationViewModel & OAuthProviderExtras, { ...ouraStatus, @@ -240,6 +257,7 @@ export const GET = apiHandler(async () => { hasOwnCredentials: !!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted, + metricFreshness: metricFreshness.oura ?? [], } satisfies IntegrationViewModel & OAuthProviderExtras, ], }); @@ -251,6 +269,13 @@ interface IntegrationViewModel { lastSuccessAt: string | null; 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. + */ + metricFreshness?: MetricFreshnessEntry[]; } interface WithingsExtras { diff --git a/src/lib/integrations/__tests__/metric-freshness.test.ts b/src/lib/integrations/__tests__/metric-freshness.test.ts new file mode 100644 index 000000000..b41e54e20 --- /dev/null +++ b/src/lib/integrations/__tests__/metric-freshness.test.ts @@ -0,0 +1,82 @@ +/** + * F-SYNC-1 — per-metric-type last-value freshness. + * + * Pins that the helper distinguishes a silently-dead metric (its newest reading + * frozen in the past) from a genuinely-current one, keyed by integration, from + * one grouped read over the live Measurement rows — the honest signal the + * per-integration "connected · 5 min ago" pill cannot carry. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const groupByMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { groupBy: groupByMock } }, +})); + +import { + getSourceMetricFreshness, + INTEGRATION_MEASUREMENT_SOURCE, +} from "../metric-freshness"; + +beforeEach(() => groupByMock.mockReset()); + +describe("getSourceMetricFreshness", () => { + it("maps grouped source/type rows to per-integration last-seen entries", async () => { + groupByMock.mockResolvedValue([ + { + source: "OURA", + type: "RESPIRATORY_RATE", + _max: { measuredAt: new Date("2026-01-01T00:00:00.000Z") }, + }, + { + source: "OURA", + type: "RECOVERY_SCORE", + _max: { measuredAt: new Date("2026-07-07T00:00:00.000Z") }, + }, + { + source: "WITHINGS", + type: "WEIGHT", + _max: { measuredAt: new Date("2026-07-06T00:00:00.000Z") }, + }, + ]); + + const result = await getSourceMetricFreshness("u1"); + + // The dead metric (respiratory rate, frozen in January) is visible as a + // distinct, older timestamp next to the current recovery score — exactly + // the "broken pipe vs healthy-idle" distinction the pill can't express. + expect(result.oura).toEqual([ + { type: "RECOVERY_SCORE", lastSeenAt: "2026-07-07T00:00:00.000Z" }, + { type: "RESPIRATORY_RATE", lastSeenAt: "2026-01-01T00:00:00.000Z" }, + ]); + expect(result.withings).toEqual([ + { type: "WEIGHT", lastSeenAt: "2026-07-06T00:00:00.000Z" }, + ]); + // Only the sync sources are queried. + expect(groupByMock.mock.calls[0][0].where.source.in).toEqual( + Object.values(INTEGRATION_MEASUREMENT_SOURCE), + ); + expect(groupByMock.mock.calls[0][0].where.deletedAt).toBeNull(); + }); + + it("skips rows with no reading and unmapped sources", async () => { + groupByMock.mockResolvedValue([ + { source: "OURA", type: "VO2_MAX", _max: { measuredAt: null } }, + { + source: "MANUAL", + type: "WEIGHT", + _max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") }, + }, + ]); + + const result = await getSourceMetricFreshness("u1"); + // A null max (no rows) yields no entry; MANUAL isn't a sync integration. + expect(result.oura).toBeUndefined(); + expect(Object.keys(result)).toHaveLength(0); + }); + + it("returns an empty map when the user has no synced measurements", async () => { + groupByMock.mockResolvedValue([]); + expect(await getSourceMetricFreshness("u1")).toEqual({}); + }); +}); diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts new file mode 100644 index 000000000..d70ac2cc4 --- /dev/null +++ b/src/lib/integrations/metric-freshness.ts @@ -0,0 +1,90 @@ +/** + * F-SYNC-1 — 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 + * empty body for ONE data type produces zero rows, throws nothing, and the + * other types in the same cycle stamp the whole integration green — so the + * Settings pill reads "connected · 5 min ago" while, say, respiratory rate has + * been dead since launch, and nothing distinguishes "broken pipe" from "healthy + * but idle". + * + * 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 …". + */ +import { prisma } from "@/lib/db"; +import type { MeasurementSource } from "@/generated/prisma/client"; +import type { IntegrationKey } from "./status"; + +/** + * 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. + */ +export const INTEGRATION_MEASUREMENT_SOURCE: Partial< + Record +> = { + withings: "WITHINGS", + whoop: "WHOOP", + fitbit: "FITBIT", + nightscout: "NIGHTSCOUT", + polar: "POLAR", + oura: "OURA", + "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; +} + +/** + * 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. + */ +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 }, + }); + + // 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> = {}; + for (const row of grouped) { + const lastSeen = row._max.measuredAt; + if (!lastSeen) continue; + const key = sourceToKey.get(row.source); + if (!key) continue; + (out[key] ??= []).push({ + type: row.type, + lastSeenAt: lastSeen.toISOString(), + }); + } + + for (const key of Object.keys(out) as IntegrationKey[]) { + out[key]!.sort((a, b) => a.type.localeCompare(b.type)); + } + return out; +}