mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(apple-health): show when each type last arrived and how
The Apple Health card listed each type's newest sample. A type the phone only hands over on "Sync all" can carry a reading from this morning and never have arrived in the background, so the sample time could not answer whether a type flows on its own. The batch route's per-type ledger now records, in one upsert per batch, when each Apple Health type last arrived, the trigger that carried it (including the new `manual`), and when a batch last brought a new or updated sample of it. Duplicate-only re-sends move the arrival but keep the last new value; scoped credentials and MANUAL rows write nothing, the same rule as the sync checkpoint. A ledger failure leaves a warn line and never fails the ingest. `GET /api/integrations/healthkit` lays `lastReceivedAt`, `lastTrigger` and `lastNewSampleAt` over each `metricFreshness` entry (null for a type no live sync has carried, absent on the workouts entry and when the ledger read failed), and the card shows them as a second line per type. The per-type newest-sample read walks live types through `listLiveMeasurementTypes` and probes each type's newest row instead of grouping every live row of the source.
This commit is contained in:
1 parent
c8470cc1e6
commit
ba99551faa
15 files changed
+976
-114
No files matched your search
+62
-7
@@ -24873,7 +24873,9 @@ components:
|
||||
description: 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or
|
||||
a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the
|
||||
account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both
|
||||
readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is
|
||||
readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the
|
||||
batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on
|
||||
each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is
|
||||
stored. Optional and backward-compatible: pre-#66 clients omit it.'
|
||||
type: string
|
||||
enum:
|
||||
@@ -72802,11 +72804,11 @@ components:
|
||||
description: Present on the GET read; omitted from the PATCH echo.
|
||||
$ref: "#/components/schemas/SyncHealth"
|
||||
metricFreshness:
|
||||
description: Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH
|
||||
echo.
|
||||
description: Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the
|
||||
GET read; omitted from the PATCH echo.
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MetricFreshnessEntry"
|
||||
$ref: "#/components/schemas/AppleHealthMetricFreshnessEntry"
|
||||
syncProgress:
|
||||
description: Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from
|
||||
the PATCH echo.
|
||||
@@ -72874,7 +72876,7 @@ components:
|
||||
additionalProperties: false
|
||||
description: Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot
|
||||
express 'this pipe stopped delivering'.
|
||||
MetricFreshnessEntry:
|
||||
AppleHealthMetricFreshnessEntry:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
@@ -72889,13 +72891,44 @@ components:
|
||||
type: boolean
|
||||
description: 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`.
|
||||
lastReceivedAt:
|
||||
description: "When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null
|
||||
when no live sync has carried it yet: a type that so far came only from the export import, or from before
|
||||
v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type.
|
||||
Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read
|
||||
failed."
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
lastTrigger:
|
||||
description: The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever
|
||||
`manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`.
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- foreground
|
||||
- background
|
||||
- push
|
||||
- manual
|
||||
description: What triggered a HealthKit batch.
|
||||
- type: "null"
|
||||
lastNewSampleAt:
|
||||
description: When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only.
|
||||
Null when none has. Same omission rules as `lastReceivedAt`.
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
required:
|
||||
- type
|
||||
- lastSeenAt
|
||||
- stale
|
||||
additionalProperties: false
|
||||
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.
|
||||
description: "Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type
|
||||
arrival facts recorded by `POST /api/measurements/batch` (v1.42)."
|
||||
AppleHealthSyncProgress:
|
||||
type: object
|
||||
properties:
|
||||
@@ -74763,6 +74796,28 @@ components:
|
||||
entry carries, plus the extras that provider has. The optional fields are not optional per request — each is
|
||||
present exactly for the providers named in its description and absent for the rest, so branch on `integration`,
|
||||
not on presence."
|
||||
MetricFreshnessEntry:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.
|
||||
lastSeenAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Newest recorded reading for this type from this source.
|
||||
stale:
|
||||
type: boolean
|
||||
description: 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`.
|
||||
required:
|
||||
- type
|
||||
- lastSeenAt
|
||||
- stale
|
||||
additionalProperties: false
|
||||
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.
|
||||
FitbitResumeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -3947,12 +3947,12 @@ model ManagedProfileHandover {
|
||||
model HealthKitTypeSync {
|
||||
userId String @map("user_id")
|
||||
type MeasurementType
|
||||
/// @pending-consumer(#1173) Last batch that carried this type.
|
||||
/// Last batch that carried this type.
|
||||
lastReceivedAt DateTime @map("last_received_at")
|
||||
/// @pending-consumer(#1173) `syncTrigger` of that batch
|
||||
/// `syncTrigger` of that batch
|
||||
/// (`foreground` / `background` / `push` / `manual`), NULL when absent.
|
||||
lastTrigger String? @map("last_trigger")
|
||||
/// @pending-consumer(#1173) Last batch that inserted or updated a sample of
|
||||
/// Last batch that inserted or updated a sample of
|
||||
/// this type (as opposed to sending only duplicates).
|
||||
lastNewSampleAt DateTime? @map("last_new_sample_at")
|
||||
|
||||
|
||||
@@ -91,8 +91,6 @@ const RELEASE_PENDING_PREFIXES: Record<string, string> = {
|
||||
"environment.fields.europeanAqi": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.uvIndexMax": "v1.42 air-quality field label (#615)",
|
||||
"environment.fields.dustMax": "v1.42 air-quality field label (#615)",
|
||||
"settings.appleHealth.freshness":
|
||||
"v1.42 per-type HealthKit freshness list (#1173)",
|
||||
"insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)",
|
||||
"insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)",
|
||||
"insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)",
|
||||
|
||||
@@ -8,12 +8,20 @@ vi.mock("@/lib/db", () => ({
|
||||
auditLog: { create: vi.fn() },
|
||||
// Per-metric freshness + first-run sync progress for the APPLE_HEALTH
|
||||
// source (#778).
|
||||
measurement: { groupBy: vi.fn(), aggregate: vi.fn() },
|
||||
measurement: { aggregate: vi.fn() },
|
||||
workout: { groupBy: vi.fn(), aggregate: vi.fn() },
|
||||
// v1.42 — the newest sample per live type is one raw probe per type, and
|
||||
// the per-type arrival ledger (#1173) is read beside it.
|
||||
$queryRaw: vi.fn(),
|
||||
healthKitTypeSync: { findMany: vi.fn() },
|
||||
},
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurements/live-types", () => ({
|
||||
listLiveMeasurementTypes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -38,6 +46,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
|
||||
import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -53,7 +62,9 @@ beforeEach(() => {
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.aggregate).mockResolvedValue({
|
||||
_count: { _all: 0 },
|
||||
@@ -281,26 +292,20 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
source: "APPLE_HEALTH",
|
||||
type: "RESPIRATORY_RATE",
|
||||
_max: { measuredAt: ago(30 * DAY) },
|
||||
},
|
||||
{
|
||||
source: "APPLE_HEALTH",
|
||||
type: "PULSE",
|
||||
_max: { measuredAt: ago(60 * 60 * 1000) },
|
||||
},
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
|
||||
"RESPIRATORY_RATE",
|
||||
"PULSE",
|
||||
]);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ type: "RESPIRATORY_RATE", last_seen: ago(30 * DAY) },
|
||||
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
|
||||
] as never);
|
||||
|
||||
const body = await read();
|
||||
// Only the Apple Health source is read.
|
||||
const groupByArgs = vi.mocked(prisma.measurement.groupBy).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
where: { source: { in: string[] } };
|
||||
};
|
||||
expect(groupByArgs.where.source.in).toEqual(["APPLE_HEALTH"]);
|
||||
expect(vi.mocked(listLiveMeasurementTypes).mock.calls[0]?.[1]).toEqual({
|
||||
source: "APPLE_HEALTH",
|
||||
});
|
||||
const byType = Object.fromEntries(
|
||||
body.metricFreshness.map((entry) => [entry.type, entry.stale]),
|
||||
);
|
||||
@@ -382,11 +387,75 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(DAY),
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.groupBy).mockRejectedValue(
|
||||
new Error("groupBy hiccup"),
|
||||
vi.mocked(listLiveMeasurementTypes).mockRejectedValue(
|
||||
new Error("live-type hiccup"),
|
||||
);
|
||||
const body = await read();
|
||||
expect(body.metricFreshness).toEqual([]);
|
||||
expect(body.syncHealth.verdict).toBe("fresh");
|
||||
});
|
||||
|
||||
// #1173 — when a type last ARRIVED and under which trigger, beside when its
|
||||
// newest sample was taken. A type that only ever comes with "Sync all" is
|
||||
// visible as `manual`; a type no live sync carried yet reads null.
|
||||
it("lays the per-type arrival facts over the freshness entries", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
|
||||
"RESPIRATORY_RATE",
|
||||
"PULSE",
|
||||
]);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ type: "RESPIRATORY_RATE", last_seen: ago(2 * DAY) },
|
||||
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
|
||||
] as never);
|
||||
vi.mocked(prisma.workout.groupBy).mockResolvedValue([
|
||||
{ source: "APPLE_HEALTH", _max: { startedAt: ago(DAY) } },
|
||||
] as never);
|
||||
const receivedAt = ago(3 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "RESPIRATORY_RATE",
|
||||
lastReceivedAt: receivedAt,
|
||||
lastTrigger: "manual",
|
||||
lastNewSampleAt: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const body = await read();
|
||||
const byType = Object.fromEntries(
|
||||
body.metricFreshness.map((entry) => [entry.type, entry]),
|
||||
);
|
||||
expect(byType.RESPIRATORY_RATE).toMatchObject({
|
||||
lastReceivedAt: receivedAt.toISOString(),
|
||||
lastTrigger: "manual",
|
||||
lastNewSampleAt: null,
|
||||
});
|
||||
expect(byType.PULSE).toMatchObject({
|
||||
lastReceivedAt: null,
|
||||
lastTrigger: null,
|
||||
lastNewSampleAt: null,
|
||||
});
|
||||
// Workouts ride their own batch route, which the ledger does not cover.
|
||||
expect(byType.WORKOUTS).not.toHaveProperty("lastReceivedAt");
|
||||
});
|
||||
|
||||
it("leaves the arrival facts off when the ledger read fails", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(DAY),
|
||||
} as never);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ type: "PULSE", last_seen: ago(DAY) },
|
||||
] as never);
|
||||
vi.mocked(prisma.healthKitTypeSync.findMany).mockRejectedValue(
|
||||
new Error("ledger hiccup"),
|
||||
);
|
||||
const body = await read();
|
||||
expect(body.metricFreshness).toHaveLength(1);
|
||||
expect(body.metricFreshness[0]).not.toHaveProperty("lastReceivedAt");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
|
||||
import {
|
||||
getHealthKitTypeArrivals,
|
||||
withHealthKitArrivals,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import { getSourceFreshness } from "@/lib/integrations/metric-freshness";
|
||||
import {
|
||||
classifyMetricFreshness,
|
||||
@@ -166,9 +170,13 @@ export const GET = apiHandler(async () => {
|
||||
legacyLastSyncedAt: lastSyncedAt,
|
||||
cadence: PUSH_CADENCE,
|
||||
});
|
||||
const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch(
|
||||
() => [],
|
||||
);
|
||||
// #1173 — when each type last ARRIVED and under which trigger, beside when
|
||||
// its newest sample was taken. Fail-soft to null, which leaves the entries
|
||||
// without arrival fields rather than reporting every type as never received.
|
||||
const [samples, arrivals] = await Promise.all([
|
||||
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
|
||||
getHealthKitTypeArrivals(user.id).catch(() => null),
|
||||
]);
|
||||
// Issue #778 — a first-run backfill used to be invisible from the web: the
|
||||
// card said "last sync 2 minutes ago" while giving no sense of how much had
|
||||
// arrived or how far back it reached. These two figures (row count + oldest
|
||||
@@ -186,7 +194,10 @@ export const GET = apiHandler(async () => {
|
||||
lastSyncTrigger,
|
||||
lastBackgroundSyncAt,
|
||||
syncHealth,
|
||||
metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict),
|
||||
metricFreshness: withHealthKitArrivals(
|
||||
classifyMetricFreshness(samples, syncHealth.verdict),
|
||||
arrivals,
|
||||
),
|
||||
syncProgress,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,16 @@ vi.mock("@/lib/db", () => ({
|
||||
fitbitConnection: { findUnique: vi.fn(async () => null) },
|
||||
googleHealthConnection: { findUnique: vi.fn(async () => null) },
|
||||
moodEntry: { count: vi.fn(async () => 0) },
|
||||
measurement: { groupBy: vi.fn(async () => []) },
|
||||
$queryRaw: vi.fn(async () => []),
|
||||
workout: { groupBy: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.42 — the per-source type walk that replaced the measurement groupBy.
|
||||
vi.mock("@/lib/measurements/live-types", () => ({
|
||||
listLiveMeasurementTypes: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
@@ -53,6 +58,7 @@ vi.mock("@/lib/oura/credentials", () => ({
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
|
||||
import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
@@ -315,7 +321,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
|
||||
});
|
||||
|
||||
it("reports the degradation instead of passing an empty list off as an answer", async () => {
|
||||
(prisma.measurement.groupBy as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
vi.mocked(listLiveMeasurementTypes).mockRejectedValue(
|
||||
new Error("statement timeout"),
|
||||
);
|
||||
|
||||
@@ -331,9 +337,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
|
||||
});
|
||||
|
||||
it("reports no degradation when the read simply found nothing", async () => {
|
||||
(prisma.measurement.groupBy as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]);
|
||||
(prisma.workout.groupBy as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const envelope = await fetchEnvelope();
|
||||
|
||||
@@ -38,7 +38,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AppleHealthCard } from "../apple-health-card";
|
||||
import { AppleHealthCard, ArrivalDetail } from "../apple-health-card";
|
||||
|
||||
function render(locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
@@ -404,3 +404,77 @@ describe("<AppleHealthCard> — sync progress (#778)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
|
||||
const now = new Date("2026-10-08T12:00:00.000Z");
|
||||
const hoursAgo = (h: number) =>
|
||||
new Date(now.getTime() - h * 60 * 60 * 1000).toISOString();
|
||||
const base = {
|
||||
type: "RESPIRATORY_RATE",
|
||||
lastSeenAt: hoursAgo(5),
|
||||
stale: false,
|
||||
};
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
const value = key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) => (node as Record<string, unknown>)?.[part],
|
||||
en,
|
||||
);
|
||||
if (typeof value !== "string") return key;
|
||||
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||
String(params?.[name] ?? `{${name}}`),
|
||||
);
|
||||
};
|
||||
const html = (entry: Parameters<typeof ArrivalDetail>[0]["entry"]) =>
|
||||
renderToStaticMarkup(<ArrivalDetail entry={entry} t={t} now={now} />);
|
||||
|
||||
it("names when a type last arrived and that only Sync all carried it", () => {
|
||||
const out = html({
|
||||
...base,
|
||||
lastReceivedAt: hoursAgo(2),
|
||||
lastTrigger: "manual",
|
||||
lastNewSampleAt: hoursAgo(2),
|
||||
});
|
||||
expect(out).toContain('data-trigger="manual"');
|
||||
expect(out).toContain("Last received 2 h ago");
|
||||
expect(out).toContain("from Sync all");
|
||||
// The last arrival brought something new, so no second date is needed.
|
||||
expect(out).not.toContain("Last new value");
|
||||
});
|
||||
|
||||
it("adds the last new value when the latest arrival held duplicates only", () => {
|
||||
const out = html({
|
||||
...base,
|
||||
lastReceivedAt: hoursAgo(1),
|
||||
lastTrigger: "background",
|
||||
lastNewSampleAt: hoursAgo(72),
|
||||
});
|
||||
expect(out).toContain("in the background");
|
||||
expect(out).toContain("Last new value 3 d ago");
|
||||
});
|
||||
|
||||
it("says a type has not arrived through live sync yet", () => {
|
||||
const out = html({
|
||||
...base,
|
||||
lastReceivedAt: null,
|
||||
lastTrigger: null,
|
||||
lastNewSampleAt: null,
|
||||
});
|
||||
expect(out).toContain('data-state="never"');
|
||||
expect(out).toContain("Not received yet");
|
||||
});
|
||||
|
||||
it("renders nothing when the server sent no arrival facts", () => {
|
||||
expect(html(base)).toBe("");
|
||||
});
|
||||
|
||||
it("titles the list by data type", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: hoursAgo(1),
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
metricFreshness: [{ ...base }],
|
||||
};
|
||||
expect(render()).toContain("By data type");
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,8 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
MetricFreshnessEntry,
|
||||
SyncHealth,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import type { HealthKitFreshnessEntry } from "@/lib/integrations/healthkit-type-sync";
|
||||
import type { SyncHealth } from "@/lib/integrations/sync-verdict";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
@@ -43,7 +41,8 @@ interface HealthKitStatus {
|
||||
/** When data last arrived without the app being opened. */
|
||||
lastBackgroundSyncAt?: string | null;
|
||||
syncHealth?: SyncHealth;
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
/** Per type: newest sample, plus when it last arrived and how (#1173). */
|
||||
metricFreshness?: HealthKitFreshnessEntry[];
|
||||
/** Server-side backfill progress (#778); null when the read failed. */
|
||||
syncProgress?: {
|
||||
recordsAccepted: number;
|
||||
@@ -135,6 +134,65 @@ function DeliveryDiagnostic({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of a type's row: when a live sync last carried the type,
|
||||
* under which trigger, and when one last brought a new value. The newest
|
||||
* sample's time on the right of the row says when the reading was TAKEN; this
|
||||
* says when the server RECEIVED it, which is what tells a type that only
|
||||
* arrives on "Sync all" from one the phone delivers by itself (#1173). Nothing
|
||||
* renders when the server sent no arrival facts (the workouts row, or a failed
|
||||
* ledger read), so absence is never painted as "not received".
|
||||
*/
|
||||
export function ArrivalDetail({
|
||||
entry,
|
||||
t,
|
||||
now,
|
||||
}: {
|
||||
entry: HealthKitFreshnessEntry;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
now?: Date;
|
||||
}) {
|
||||
if (entry.lastReceivedAt === undefined) return null;
|
||||
if (entry.lastReceivedAt === null) {
|
||||
return (
|
||||
<span data-testid="apple-health-arrival" data-state="never">
|
||||
{t("settings.appleHealth.freshness.never")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const reference = (now ?? new Date()).getTime();
|
||||
const relative = (iso: string) =>
|
||||
formatRelative(Math.max(0, reference - new Date(iso).getTime()), t);
|
||||
const trigger = entry.lastTrigger ?? null;
|
||||
const parts = [
|
||||
t("settings.appleHealth.freshness.lastReceived", {
|
||||
relative: relative(entry.lastReceivedAt),
|
||||
}),
|
||||
t(
|
||||
trigger
|
||||
? TRIGGER_KEYS[trigger]
|
||||
: "settings.appleHealth.delivery.trigger.unknown",
|
||||
),
|
||||
];
|
||||
// Only worth a mention when the last arrival brought duplicates alone.
|
||||
if (entry.lastNewSampleAt && entry.lastNewSampleAt !== entry.lastReceivedAt) {
|
||||
parts.push(
|
||||
t("settings.appleHealth.freshness.lastNewValue", {
|
||||
relative: relative(entry.lastNewSampleAt),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid="apple-health-arrival"
|
||||
data-state="received"
|
||||
data-trigger={trigger ?? "unknown"}
|
||||
>
|
||||
{parts.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run backfill progress (#778). During the initial HealthKit backfill
|
||||
* the card said nothing about how much had arrived or how far back it
|
||||
@@ -269,6 +327,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
|
||||
<MetricFreshnessDisclosure
|
||||
entries={status?.metricFreshness}
|
||||
idPrefix="apple-health"
|
||||
title={t("settings.appleHealth.freshness.title")}
|
||||
detail={(entry) => <ArrivalDetail entry={entry} t={t} />}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* "expected but missing" row is invented.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { AlertTriangle, ChevronDown } from "lucide-react";
|
||||
|
||||
import { formatRelative } from "@/components/settings/integration-status-pill";
|
||||
@@ -42,16 +42,27 @@ function humanise(type: string): string {
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
export function MetricFreshnessDisclosure({
|
||||
export function MetricFreshnessDisclosure<
|
||||
Entry extends MetricFreshnessEntry = MetricFreshnessEntry,
|
||||
>({
|
||||
entries,
|
||||
idPrefix,
|
||||
now,
|
||||
title,
|
||||
detail,
|
||||
}: {
|
||||
entries: MetricFreshnessEntry[] | undefined;
|
||||
entries: Entry[] | undefined;
|
||||
/** Namespaces the disclosure's ids so several cards can coexist. */
|
||||
idPrefix: string;
|
||||
/** Override "now" for deterministic testing. */
|
||||
now?: Date;
|
||||
/** Replaces the shared "received data" heading. */
|
||||
title?: string;
|
||||
/**
|
||||
* A second line under a row, for a card that knows more per type than the
|
||||
* newest sample (the Apple Health card's arrival facts, #1173).
|
||||
*/
|
||||
detail?: (entry: Entry) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -101,7 +112,7 @@ export function MetricFreshnessDisclosure({
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.integrationFreshness.title")}
|
||||
{title ?? t("settings.integrationFreshness.title")}
|
||||
</span>
|
||||
<span className="text-muted-foreground min-w-0 truncate text-xs">
|
||||
{t("settings.integrationFreshness.summary", {
|
||||
@@ -119,34 +130,44 @@ export function MetricFreshnessDisclosure({
|
||||
|
||||
{open && (
|
||||
<ul id={panelId} className="space-y-1">
|
||||
{sorted.map((entry) => (
|
||||
<li
|
||||
key={entry.type}
|
||||
data-slot="metric-freshness-row"
|
||||
data-metric={entry.type}
|
||||
data-state={entry.stale ? "stale" : "fresh"}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 text-xs",
|
||||
entry.stale ? "text-warning" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{entry.stale ? (
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{label(entry.type)}</span>
|
||||
</span>
|
||||
<time
|
||||
dateTime={entry.lastSeenAt}
|
||||
className="shrink-0 tabular-nums"
|
||||
{sorted.map((entry) => {
|
||||
const extra = detail?.(entry);
|
||||
return (
|
||||
<li
|
||||
key={entry.type}
|
||||
data-slot="metric-freshness-row"
|
||||
data-metric={entry.type}
|
||||
data-state={entry.stale ? "stale" : "fresh"}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
entry.stale ? "text-warning" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{relative(entry.lastSeenAt)}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{entry.stale ? (
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{label(entry.type)}</span>
|
||||
</span>
|
||||
<time
|
||||
dateTime={entry.lastSeenAt}
|
||||
className="shrink-0 tabular-nums"
|
||||
>
|
||||
{relative(entry.lastSeenAt)}
|
||||
</time>
|
||||
</div>
|
||||
{extra ? (
|
||||
<div data-slot="metric-freshness-detail" className="mt-0.5">
|
||||
{extra}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,14 +8,35 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const groupByMock = vi.hoisted(() => vi.fn());
|
||||
/**
|
||||
* The fixtures keep the grouped-row shape (`source`, `type`, `_max`) the read
|
||||
* used to take. Since v1.42 the module asks `listLiveMeasurementTypes` per
|
||||
* source and then probes each type's newest row in one raw query; the two
|
||||
* mocks below answer both from the same fixture rows.
|
||||
*/
|
||||
type GroupedRow = {
|
||||
source: string;
|
||||
type: string;
|
||||
_max: { measuredAt: Date | null };
|
||||
};
|
||||
let measurementRows: GroupedRow[] = [];
|
||||
const groupByMock = {
|
||||
mockResolvedValue(rows: GroupedRow[]) {
|
||||
measurementRows = rows;
|
||||
},
|
||||
};
|
||||
const liveTypesMock = vi.hoisted(() => vi.fn());
|
||||
const queryRawMock = vi.hoisted(() => vi.fn());
|
||||
const workoutGroupByMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { groupBy: groupByMock },
|
||||
$queryRaw: queryRawMock,
|
||||
workout: { groupBy: workoutGroupByMock },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/measurements/live-types", () => ({
|
||||
listLiveMeasurementTypes: liveTypesMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getSourceFreshness,
|
||||
@@ -28,7 +49,32 @@ import {
|
||||
} from "../sync-verdict";
|
||||
|
||||
beforeEach(() => {
|
||||
groupByMock.mockReset();
|
||||
measurementRows = [];
|
||||
liveTypesMock.mockReset();
|
||||
liveTypesMock.mockImplementation(
|
||||
async (_userId: string, options: { source: string }) =>
|
||||
measurementRows
|
||||
.filter((row) => row.source === options.source)
|
||||
.map((row) => row.type),
|
||||
);
|
||||
queryRawMock.mockReset();
|
||||
queryRawMock.mockImplementation(
|
||||
async (_strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const types = values.find(Array.isArray) as string[];
|
||||
const source = values.find(
|
||||
(value) =>
|
||||
typeof value === "string" &&
|
||||
measurementRows.some((row) => row.source === value),
|
||||
);
|
||||
return types.map((type) => ({
|
||||
type,
|
||||
last_seen:
|
||||
measurementRows.find(
|
||||
(row) => row.source === source && row.type === type,
|
||||
)?._max.measuredAt ?? null,
|
||||
}));
|
||||
},
|
||||
);
|
||||
workoutGroupByMock.mockReset();
|
||||
workoutGroupByMock.mockResolvedValue([]);
|
||||
});
|
||||
@@ -65,11 +111,10 @@ describe("getSourceMetricFreshness", () => {
|
||||
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();
|
||||
// Only the sync sources are asked about, one live-type walk each.
|
||||
expect(
|
||||
liveTypesMock.mock.calls.map((call) => call[1].source).sort(),
|
||||
).toEqual([...Object.values(INTEGRATION_MEASUREMENT_SOURCE)].sort());
|
||||
});
|
||||
|
||||
it("skips rows with no reading and unmapped sources", async () => {
|
||||
@@ -175,9 +220,30 @@ describe("getSourceFreshness — one source, for Apple Health", () => {
|
||||
expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([
|
||||
{ type: "RESPIRATORY_RATE", lastSeenAt: "2026-07-01T00:00:00.000Z" },
|
||||
]);
|
||||
expect(groupByMock.mock.calls[0][0].where.source.in).toEqual([
|
||||
"APPLE_HEALTH",
|
||||
expect(liveTypesMock).toHaveBeenCalledTimes(1);
|
||||
expect(liveTypesMock.mock.calls[0][1]).toEqual({ source: "APPLE_HEALTH" });
|
||||
});
|
||||
|
||||
it("issues no per-type probe when the source has no live types", async () => {
|
||||
expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([]);
|
||||
expect(queryRawMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("probes live rows of the one source only", async () => {
|
||||
groupByMock.mockResolvedValue([
|
||||
{
|
||||
source: "APPLE_HEALTH",
|
||||
type: "PULSE",
|
||||
_max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") },
|
||||
},
|
||||
]);
|
||||
await getSourceFreshness("u1", "APPLE_HEALTH");
|
||||
const sql = (queryRawMock.mock.calls[0][0] as TemplateStringsArray).join(
|
||||
"?",
|
||||
);
|
||||
expect(sql).toContain('"deleted_at" IS NULL');
|
||||
expect(sql).toContain('"source" = ?::"measurement_source"');
|
||||
expect(sql).toContain("LIMIT 1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,13 +7,30 @@
|
||||
* it to show which types are actually flowing. A scoped ingest credential
|
||||
* does not write it, the same rule as `lastSyncedAt`.
|
||||
*
|
||||
* Contract stub: the signature is final; the body records nothing yet.
|
||||
* Sample time (`max(measuredAt)`, what the freshness list showed before) and
|
||||
* arrival time are different questions. A type the phone only hands over
|
||||
* when someone taps "Sync all" can carry a reading from this morning and
|
||||
* still never have arrived in the background; only the arrival time and the
|
||||
* trigger that carried it can show that.
|
||||
*/
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logCaught } from "@/lib/logging/signal";
|
||||
import {
|
||||
WORKOUT_FRESHNESS_TYPE,
|
||||
type MetricFreshnessEntry,
|
||||
} from "./sync-verdict";
|
||||
|
||||
export type HealthKitSyncTrigger =
|
||||
"foreground" | "background" | "push" | "manual";
|
||||
|
||||
const TRIGGERS: ReadonlySet<string> = new Set<HealthKitSyncTrigger>([
|
||||
"foreground",
|
||||
"background",
|
||||
"push",
|
||||
"manual",
|
||||
]);
|
||||
|
||||
/** What one batch did for one type. */
|
||||
export interface HealthKitTypeArrival {
|
||||
/** Entries of this type the batch accepted (inserted, updated or duplicate). */
|
||||
@@ -22,6 +39,16 @@ export interface HealthKitTypeArrival {
|
||||
newSamples: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One statement for the whole batch (`unnest` over at most a few dozen
|
||||
* types). `last_received_at` and `last_trigger` describe the latest batch
|
||||
* that carried the type, so they are overwritten; `last_new_sample_at` only
|
||||
* moves when this batch brought something new, so a duplicates-only re-send
|
||||
* keeps the earlier date standing.
|
||||
*
|
||||
* Best effort: the ledger is a diagnostic beside rows that are already
|
||||
* written, so a failure here leaves a `warn` line and never fails the ingest.
|
||||
*/
|
||||
export async function recordHealthKitTypeArrivals(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
@@ -31,6 +58,111 @@ export async function recordHealthKitTypeArrivals(
|
||||
at?: Date;
|
||||
},
|
||||
): Promise<void> {
|
||||
void tx;
|
||||
void input;
|
||||
const types: string[] = [];
|
||||
const brought: boolean[] = [];
|
||||
for (const [type, arrival] of input.counts) {
|
||||
if (arrival.accepted <= 0) continue;
|
||||
types.push(type);
|
||||
brought.push(arrival.newSamples > 0);
|
||||
}
|
||||
if (types.length === 0) return;
|
||||
// An ISO string cast in SQL, not a JS `Date`: a Date parameter is
|
||||
// serialised in the process's local zone and these are zone-less UTC
|
||||
// columns (same reasoning as the access-token sunset in refresh-token.ts).
|
||||
const at = (input.at ?? new Date()).toISOString();
|
||||
try {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "healthkit_type_syncs"
|
||||
("user_id", "type", "last_received_at", "last_trigger", "last_new_sample_at")
|
||||
SELECT ${input.userId}, u.t::"measurement_type",
|
||||
${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger},
|
||||
CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END
|
||||
FROM unnest(${types}::text[], ${brought}::boolean[]) AS u(t, n)
|
||||
ON CONFLICT ("user_id", "type") DO UPDATE SET
|
||||
"last_received_at" = EXCLUDED."last_received_at",
|
||||
"last_trigger" = EXCLUDED."last_trigger",
|
||||
"last_new_sample_at" = COALESCE(
|
||||
EXCLUDED."last_new_sample_at",
|
||||
"healthkit_type_syncs"."last_new_sample_at"
|
||||
)
|
||||
`;
|
||||
} catch (error) {
|
||||
logCaught("healthkit.type_sync.record_failed", error, {
|
||||
types: types.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** One type's arrival facts, as the Apple Health status read serves them. */
|
||||
export interface HealthKitTypeArrivalRecord {
|
||||
lastReceivedAt: string;
|
||||
lastTrigger: HealthKitSyncTrigger | null;
|
||||
lastNewSampleAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every recorded type for one account, keyed by `MeasurementType`. A stored
|
||||
* trigger outside the known set (none is written, but the column is text)
|
||||
* reads as null rather than reaching the client as an unknown label.
|
||||
*/
|
||||
export async function getHealthKitTypeArrivals(
|
||||
userId: string,
|
||||
): Promise<Map<string, HealthKitTypeArrivalRecord>> {
|
||||
const rows = await prisma.healthKitTypeSync.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
type: true,
|
||||
lastReceivedAt: true,
|
||||
lastTrigger: true,
|
||||
lastNewSampleAt: true,
|
||||
},
|
||||
});
|
||||
const out = new Map<string, HealthKitTypeArrivalRecord>();
|
||||
for (const row of rows) {
|
||||
out.set(row.type, {
|
||||
lastReceivedAt: row.lastReceivedAt.toISOString(),
|
||||
lastTrigger:
|
||||
row.lastTrigger && TRIGGERS.has(row.lastTrigger)
|
||||
? (row.lastTrigger as HealthKitSyncTrigger)
|
||||
: null,
|
||||
lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A freshness entry with the arrival facts beside the newest sample. */
|
||||
export interface HealthKitFreshnessEntry extends MetricFreshnessEntry {
|
||||
/** When a live sync last carried this type; null when none has. */
|
||||
lastReceivedAt?: string | null;
|
||||
/** The trigger of that sync; null when absent or not reported. */
|
||||
lastTrigger?: HealthKitSyncTrigger | null;
|
||||
/** When a live sync last brought a new or updated sample of it. */
|
||||
lastNewSampleAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the arrival ledger over the per-type freshness entries. Every
|
||||
* measurement type gets the three fields, null when no live sync has carried
|
||||
* it yet (a type that so far came only from the export import, or from before
|
||||
* the ledger existed). The `WORKOUTS` entry is left without them: workouts
|
||||
* arrive through their own batch route, which the ledger does not cover, and
|
||||
* "not received yet" would be false. A null ledger (the read failed) leaves
|
||||
* every entry as it was rather than claiming nothing arrived.
|
||||
*/
|
||||
export function withHealthKitArrivals(
|
||||
entries: readonly MetricFreshnessEntry[],
|
||||
arrivals: ReadonlyMap<string, HealthKitTypeArrivalRecord> | null,
|
||||
): HealthKitFreshnessEntry[] {
|
||||
if (!arrivals) return [...entries];
|
||||
return entries.map((entry) => {
|
||||
if (entry.type === WORKOUT_FRESHNESS_TYPE) return entry;
|
||||
const arrival = arrivals.get(entry.type);
|
||||
return {
|
||||
...entry,
|
||||
lastReceivedAt: arrival?.lastReceivedAt ?? null,
|
||||
lastTrigger: arrival?.lastTrigger ?? null,
|
||||
lastNewSampleAt: arrival?.lastNewSampleAt ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
* 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
|
||||
* `Measurement` table 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
|
||||
* classification of "quiet" vs "current" is a pure function next to the verdict
|
||||
@@ -22,7 +22,11 @@
|
||||
* pseudo-entry so those pipes are visible too.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
|
||||
import type { IntegrationKey } from "./status";
|
||||
import {
|
||||
WORKOUT_FRESHNESS_TYPE,
|
||||
@@ -70,34 +74,68 @@ export const INTEGRATION_WORKOUT_SOURCE: Partial<
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Newest measurement per `(source, type)` for the given sources. 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.
|
||||
*
|
||||
* v1.42: this used to be one `groupBy … _max(measuredAt)` over every live row
|
||||
* of the sources, which on a multi-year Apple Health history reads six figures
|
||||
* of rows for a two-digit answer on every settings visit. It now asks which
|
||||
* types are live per source (`listLiveMeasurementTypes`, a loose index scan)
|
||||
* and then probes each type's newest row through the same partial covering
|
||||
* index, one `LIMIT 1` per type.
|
||||
*/
|
||||
export async function getMeasurementFreshnessBySource(
|
||||
userId: string,
|
||||
sources: readonly MeasurementSource[],
|
||||
): Promise<Map<MeasurementSource, MetricFreshnessSample[]>> {
|
||||
const out = new Map<MeasurementSource, MetricFreshnessSample[]>();
|
||||
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);
|
||||
const perSource = await Promise.all(
|
||||
[...new Set(sources)].map(async (source) => {
|
||||
const types = await listLiveMeasurementTypes(userId, { source });
|
||||
return { source, samples: await newestPerType(userId, source, types) };
|
||||
}),
|
||||
);
|
||||
for (const { source, samples } of perSource) {
|
||||
if (samples.length > 0) out.set(source, samples);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function newestPerType(
|
||||
userId: string,
|
||||
source: MeasurementSource,
|
||||
types: readonly MeasurementType[],
|
||||
): Promise<MetricFreshnessSample[]> {
|
||||
if (types.length === 0) return [];
|
||||
// Parameter-bound throughout; the enum casts are literal SQL.
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ type: string; last_seen: Date | null }>
|
||||
>`
|
||||
SELECT live."type"::text AS "type",
|
||||
(
|
||||
SELECT m."measured_at"
|
||||
FROM "measurements" m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = live."type"
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."source" = ${source}::"measurement_source"
|
||||
ORDER BY m."measured_at" DESC
|
||||
LIMIT 1
|
||||
) AS "last_seen"
|
||||
FROM unnest(${[...types]}::"measurement_type"[]) AS live("type")
|
||||
`;
|
||||
const samples: MetricFreshnessSample[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.last_seen) continue;
|
||||
samples.push({
|
||||
type: row.type,
|
||||
lastSeenAt: new Date(row.last_seen).toISOString(),
|
||||
});
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest workout per source. `Workout` rows are hard-deleted, so there is no
|
||||
* tombstone predicate to apply.
|
||||
|
||||
@@ -117,6 +117,38 @@ const metricFreshnessEntry = z
|
||||
"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.",
|
||||
});
|
||||
|
||||
// #1173 — the Apple Health read adds when each type last ARRIVED, under which
|
||||
// trigger, and when a sync last brought something new for it. Mirrors
|
||||
// `HealthKitFreshnessEntry` in `src/lib/integrations/healthkit-type-sync.ts`.
|
||||
const appleHealthMetricFreshnessEntry = metricFreshnessEntry
|
||||
.extend({
|
||||
lastReceivedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null when no live sync has carried it yet: a type that so far came only from the export import, or from before v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read failed.",
|
||||
),
|
||||
lastTrigger: healthKitSyncTriggerEnum
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever `manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`.",
|
||||
),
|
||||
lastNewSampleAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Null when none has. Same omission rules as `lastReceivedAt`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AppleHealthMetricFreshnessEntry",
|
||||
description:
|
||||
"Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42).",
|
||||
});
|
||||
|
||||
// Issue #778 — the two backfill-progress figures the server genuinely holds.
|
||||
// The iOS app drives the backfill; its queue, throttle state, and any ETA
|
||||
// live on the device and are deliberately absent here.
|
||||
@@ -167,10 +199,10 @@ const healthKitConfigResponse = z
|
||||
.optional()
|
||||
.describe("Present on the GET read; omitted from the PATCH echo."),
|
||||
metricFreshness: z
|
||||
.array(metricFreshnessEntry)
|
||||
.array(appleHealthMetricFreshnessEntry)
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.",
|
||||
"Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the GET read; omitted from the PATCH echo.",
|
||||
),
|
||||
syncProgress: appleHealthSyncProgress
|
||||
.nullable()
|
||||
|
||||
@@ -130,7 +130,7 @@ const batchPayloadSchema = z
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.optional()
|
||||
.describe(
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Per-type HealthKit arrival ledger (#1173), against real Postgres.
|
||||
*
|
||||
* The Apple Health card used to show only when each type's newest SAMPLE was
|
||||
* taken. A type the phone hands over only on "Sync all" can carry a reading
|
||||
* from this morning and still never arrive in the background, so the card
|
||||
* needs when the server RECEIVED each type and under which trigger. These
|
||||
* cases pin the batch route's write, the duplicate rule for
|
||||
* `lastNewSampleAt`, the scoped-credential exclusion, and the status read
|
||||
* that serves it, including the newest-sample probe that replaced the
|
||||
* grouped read.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-healthkit-type-sync-32-bytes-minimum-123456";
|
||||
|
||||
const { hashToken } = await import("@/lib/auth/hmac");
|
||||
|
||||
const USER_ID = "user-healthkit-type-sync";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "healthkit-type-sync",
|
||||
email: "healthkit-type-sync@example.test",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function armSession(): Promise<void> {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
function entry(
|
||||
hkIdentifier: string,
|
||||
value: number,
|
||||
unit: string,
|
||||
at: string,
|
||||
externalId: string,
|
||||
) {
|
||||
return { hkIdentifier, value, unit, startDate: at, endDate: at, externalId };
|
||||
}
|
||||
|
||||
async function postBatch(
|
||||
body: Record<string, unknown>,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<Response> {
|
||||
const { POST } = await import("@/app/api/measurements/batch/route");
|
||||
return POST(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const T1 = "2026-10-01T06:00:00.000Z";
|
||||
const T2 = "2026-10-01T07:00:00.000Z";
|
||||
|
||||
describe("per-type arrival ledger (#1173)", () => {
|
||||
it("records each type a batch carried, with its trigger", async () => {
|
||||
await armSession();
|
||||
const res = await postBatch({
|
||||
syncTrigger: "manual",
|
||||
entries: [
|
||||
entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"),
|
||||
entry(
|
||||
"HKQuantityTypeIdentifierHeartRate",
|
||||
61,
|
||||
"count/min",
|
||||
T1,
|
||||
"uuid-p1",
|
||||
),
|
||||
entry(
|
||||
"HKQuantityTypeIdentifierHeartRate",
|
||||
63,
|
||||
"count/min",
|
||||
T2,
|
||||
"uuid-p2",
|
||||
),
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const rows = await getPrismaClient().healthKitTypeSync.findMany({
|
||||
where: { userId: USER_ID },
|
||||
orderBy: { type: "asc" },
|
||||
});
|
||||
expect(rows.map((row) => row.type).sort()).toEqual(["PULSE", "WEIGHT"]);
|
||||
for (const row of rows) {
|
||||
expect(row.lastTrigger).toBe("manual");
|
||||
expect(row.lastNewSampleAt?.toISOString()).toBe(
|
||||
row.lastReceivedAt.toISOString(),
|
||||
);
|
||||
// Stored as the instant it is, whatever zone the process runs in.
|
||||
expect(Math.abs(row.lastReceivedAt.getTime() - Date.now())).toBeLessThan(
|
||||
60_000,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves the arrival but keeps the last new value on a duplicates-only re-send", async () => {
|
||||
await armSession();
|
||||
const weight = entry(
|
||||
"HKQuantityTypeIdentifierBodyMass",
|
||||
70,
|
||||
"kg",
|
||||
T1,
|
||||
"uuid-w1",
|
||||
);
|
||||
await postBatch({ syncTrigger: "foreground", entries: [weight] });
|
||||
const first = await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({
|
||||
where: { userId_type: { userId: USER_ID, type: "WEIGHT" } },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await postBatch({ syncTrigger: "background", entries: [weight] });
|
||||
const second = await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({
|
||||
where: { userId_type: { userId: USER_ID, type: "WEIGHT" } },
|
||||
});
|
||||
|
||||
expect(second.lastTrigger).toBe("background");
|
||||
expect(second.lastReceivedAt.getTime()).toBeGreaterThan(
|
||||
first.lastReceivedAt.getTime(),
|
||||
);
|
||||
expect(second.lastNewSampleAt?.toISOString()).toBe(
|
||||
first.lastNewSampleAt?.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores a missing trigger as null rather than keeping the previous one", async () => {
|
||||
await armSession();
|
||||
await postBatch({
|
||||
syncTrigger: "push",
|
||||
entries: [
|
||||
entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"),
|
||||
],
|
||||
});
|
||||
await postBatch({
|
||||
entries: [
|
||||
entry("HKQuantityTypeIdentifierBodyMass", 71, "kg", T2, "uuid-w2"),
|
||||
],
|
||||
});
|
||||
const row = await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({
|
||||
where: { userId_type: { userId: USER_ID, type: "WEIGHT" } },
|
||||
});
|
||||
expect(row.lastTrigger).toBeNull();
|
||||
});
|
||||
|
||||
it("records nothing for MANUAL rows or for a scoped credential", async () => {
|
||||
await armSession();
|
||||
await postBatch({
|
||||
syncTrigger: "foreground",
|
||||
entries: [
|
||||
{
|
||||
...entry(
|
||||
"HKQuantityTypeIdentifierBodyMass",
|
||||
70,
|
||||
"kg",
|
||||
T1,
|
||||
"manual-w1",
|
||||
),
|
||||
source: "MANUAL",
|
||||
},
|
||||
],
|
||||
});
|
||||
cookieJar.clear();
|
||||
|
||||
const raw = `hlk_typesync_${"0".repeat(48)}`;
|
||||
await getPrismaClient().apiToken.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "bridge",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["measurements:write"],
|
||||
},
|
||||
});
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
const res = await postBatch(
|
||||
{
|
||||
syncTrigger: "background",
|
||||
entries: [
|
||||
entry("HKQuantityTypeIdentifierBodyMass", 72, "kg", T2, "uuid-b1"),
|
||||
],
|
||||
},
|
||||
{ authorization: `Bearer ${raw}` },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(
|
||||
await getPrismaClient().healthKitTypeSync.count({
|
||||
where: { userId: USER_ID },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("serves the arrival facts beside the newest live sample", async () => {
|
||||
await armSession();
|
||||
await postBatch({
|
||||
syncTrigger: "manual",
|
||||
entries: [
|
||||
entry(
|
||||
"HKQuantityTypeIdentifierRespiratoryRate",
|
||||
14,
|
||||
"count/min",
|
||||
T1,
|
||||
"uuid-r1",
|
||||
),
|
||||
entry(
|
||||
"HKQuantityTypeIdentifierRespiratoryRate",
|
||||
15,
|
||||
"count/min",
|
||||
T2,
|
||||
"uuid-r2",
|
||||
),
|
||||
],
|
||||
});
|
||||
// A type that came only from the export import: live rows, no arrival.
|
||||
await getPrismaClient().measurement.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
type: "WEIGHT",
|
||||
value: 70,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(T1),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "xml-w1",
|
||||
},
|
||||
});
|
||||
// The newest respiratory row is a tombstone; the read must skip it.
|
||||
await getPrismaClient().measurement.updateMany({
|
||||
where: { userId: USER_ID, externalId: "uuid-r2" },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/integrations/healthkit/route");
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/integrations/healthkit"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metricFreshness: Array<{
|
||||
type: string;
|
||||
lastSeenAt: string;
|
||||
lastReceivedAt?: string | null;
|
||||
lastTrigger?: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const byType = Object.fromEntries(
|
||||
body.data.metricFreshness.map((row) => [row.type, row]),
|
||||
);
|
||||
expect(byType.RESPIRATORY_RATE.lastSeenAt).toBe(T1);
|
||||
expect(byType.RESPIRATORY_RATE.lastTrigger).toBe("manual");
|
||||
expect(byType.RESPIRATORY_RATE.lastReceivedAt).toEqual(expect.any(String));
|
||||
expect(byType.WEIGHT).toMatchObject({
|
||||
lastSeenAt: T1,
|
||||
lastReceivedAt: null,
|
||||
lastTrigger: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user