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:
Marc-André Bombeck committed 2026-10-08 09:22:49 +02:00
1 parent c8470cc1e6
commit ba99551faa
15 files changed
+976 -114

No files matched your search

+62 -7
View File
@@ -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:
+3 -3
View File
@@ -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");
});
});
+15 -4
View File
@@ -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");
});
});
+135 -3
View File
@@ -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,
};
});
}
+58 -20
View File
@@ -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.
+34 -2
View File
@@ -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()
+1 -1
View File
@@ -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,
});
});
});