mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Read Apple Health liveness off the data, not only the batch stamp
The status verdict looked only at the user's last-batch column, which moves on a live batch alone. Rows that reached the account another way, such as the export.zip import, left it null, and the card said 'waiting for first data' beside a four-digit record count. The verdict now takes the newest of that stamp, the per-type arrival ledger and the newest stored sample.
This commit is contained in:
1 parent
ac482c508d
commit
f7b6ea5dad
3 files changed
+78
-15
No files matched your search
@@ -287,6 +287,40 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
expect((await read()).syncHealth.verdict).toBe("pending_first_sync");
|
||||
});
|
||||
|
||||
// The column only moves on a live batch. Rows that reached the account any
|
||||
// other way (the export.zip import, a batch before the column existed) left
|
||||
// it null, and the card said "waiting for first data" beside a four-digit
|
||||
// record count. Arrival is a data question: any Apple Health row settles it.
|
||||
it("is not pending when the per-type ledger records an arrival", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "PULSE",
|
||||
lastReceivedAt: ago(2 * DAY),
|
||||
lastTrigger: null,
|
||||
lastNewSampleAt: null,
|
||||
},
|
||||
] as never);
|
||||
expect((await read()).syncHealth.verdict).toBe("fresh");
|
||||
});
|
||||
|
||||
it("is not pending when Apple Health rows exist without any sync stamp", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: null,
|
||||
} as never);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ type: "PULSE", last_seen: ago(DAY) },
|
||||
] as never);
|
||||
const body = await read();
|
||||
expect(body.syncHealth.verdict).toBe("fresh");
|
||||
expect(body.metricFreshness.map((e) => e.stale)).toEqual([false]);
|
||||
});
|
||||
|
||||
it("flags one quiet metric inside an otherwise healthy connection", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
|
||||
import {
|
||||
getHealthKitTypeArrivals,
|
||||
newestAppleHealthDataAt,
|
||||
withHealthKitArrivals,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import { getSourceFreshness } from "@/lib/integrations/metric-freshness";
|
||||
@@ -162,21 +163,9 @@ export const GET = apiHandler(async () => {
|
||||
// three-week-dead pipe rendered the same green as one that delivered an hour
|
||||
// ago. Fail-soft on the per-metric read — an honesty signal is never worth
|
||||
// 500-ing the config the iOS client needs.
|
||||
// No `connected` hint: Apple Health has nothing to connect or disconnect, so
|
||||
// "never delivered" resolves to `pending_first_sync`, not `disconnected`.
|
||||
const syncHealth = resolveSyncVerdict({
|
||||
configured: true,
|
||||
lastDataAt: lastSyncedAt,
|
||||
legacyLastSyncedAt: lastSyncedAt,
|
||||
cadence: PUSH_CADENCE,
|
||||
});
|
||||
// #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
|
||||
@@ -184,9 +173,23 @@ export const GET = apiHandler(async () => {
|
||||
// progress; the phone's queue and throttle state stay on the phone and are
|
||||
// not guessed at. Fail-soft for the same reason the freshness read is —
|
||||
// a progress line is never worth 500-ing the config the iOS client needs.
|
||||
const syncProgress = await getAppleHealthSyncProgress(user.id).catch(
|
||||
() => null,
|
||||
);
|
||||
const [samples, arrivals, syncProgress] = await Promise.all([
|
||||
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
|
||||
getHealthKitTypeArrivals(user.id).catch(() => null),
|
||||
getAppleHealthSyncProgress(user.id).catch(() => null),
|
||||
]);
|
||||
// No `connected` hint: Apple Health has nothing to connect or disconnect, so
|
||||
// "never delivered" resolves to `pending_first_sync`, not `disconnected`.
|
||||
// "Delivered" is read off the data, not only off the user column: that
|
||||
// column moves on a live batch alone, so rows from the export.zip import
|
||||
// left it null and the card said "waiting for first data" beside a
|
||||
// four-digit record count.
|
||||
const syncHealth = resolveSyncVerdict({
|
||||
configured: true,
|
||||
lastDataAt: newestAppleHealthDataAt(lastSyncedAt, arrivals, samples),
|
||||
legacyLastSyncedAt: lastSyncedAt,
|
||||
cadence: PUSH_CADENCE,
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
entries,
|
||||
|
||||
@@ -131,6 +131,32 @@ export async function getHealthKitTypeArrivals(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest instant Apple Health data is known to have reached the account:
|
||||
* the live-batch stamp, the per-type arrival ledger, or — for rows that came
|
||||
* any other way, such as the export.zip import — the newest stored sample.
|
||||
* Null only when none of the three holds anything, which is the one case the
|
||||
* status may honestly call "waiting for first data".
|
||||
*/
|
||||
export function newestAppleHealthDataAt(
|
||||
lastSyncedAt: string | null,
|
||||
arrivals: Map<string, HealthKitTypeArrivalRecord> | null,
|
||||
samples: readonly { lastSeenAt: string }[],
|
||||
): string | null {
|
||||
let newest: number | null = null;
|
||||
const consider = (value: string | null | undefined) => {
|
||||
if (!value) return;
|
||||
const ms = Date.parse(value);
|
||||
if (Number.isFinite(ms) && (newest === null || ms > newest)) newest = ms;
|
||||
};
|
||||
consider(lastSyncedAt);
|
||||
for (const arrival of arrivals?.values() ?? []) {
|
||||
consider(arrival.lastReceivedAt);
|
||||
}
|
||||
for (const sample of samples) consider(sample.lastSeenAt);
|
||||
return newest === null ? null : new Date(newest).toISOString();
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
||||
Reference in new issue
Block a user