mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user