mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
081bb2289d
commit
4c158f6abc
3 files changed
+197
No files matched your search
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user