feat(integrations): per-metric-type last-value freshness signal

Freshness was tracked only per-integration, so a provider returning a
healthy 200 with an empty body for ONE metric type recorded no failure and
the whole integration stamped green — a metric dead since launch was
indistinguishable from a healthy-idle one. Compute the last-value
timestamp per (source, type) straight from the Measurement table (no
migration) and surface it per integration on the status endpoint, so the
Settings card can flag a silently-dead metric the green pill hides. The
card UI badge + per-cadence staleness classification are the deferred
remainder.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:55:35 +02:00
1 parent 081bb2289d
commit 4c158f6abc
3 files changed
+197

No files matched your search

+25
View File
@@ -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<Record<IntegrationKey, MetricFreshnessEntry[]>>,
);
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 {
@@ -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({});
});
});
+90
View File
@@ -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<IntegrationKey, MeasurementSource>
> = {
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<Partial<Record<IntegrationKey, MetricFreshnessEntry[]>>> {
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<MeasurementSource, IntegrationKey>(
(
Object.entries(INTEGRATION_MEASUREMENT_SOURCE) as Array<
[IntegrationKey, MeasurementSource]
>
).map(([key, source]) => [source, key]),
);
const out: Partial<Record<IntegrationKey, MetricFreshnessEntry[]>> = {};
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;
}