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

@@ -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>