mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(integrations): tell the Apple Health card how the last sync arrived
The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the wide event, where no human reads it. Half of what was promised got built: the value is written and appears in three places in the codebase, none of which is a screen anyone looks at. Persist it on the account, alongside the instant of the most recent batch that arrived without the app being opened, and publish both from `GET /api/integrations/healthkit`. The card now names how the last sync arrived and when data last came in on its own. `healthKitLastSyncedAt` alone reads identically whether the phone delivers by itself or only while someone has the app open, which is why "is background delivery working" could not be settled from either side. A batch that declares no trigger writes null rather than leaving the previous one standing — the field describes this sync, and an older client's silence is not evidence about it. A foreground batch leaves the background timestamp alone; overwriting it would erase the only answer the card has. Also here: WHOOP, Fitbit and Google Health join the workout-freshness table. All three write workouts, and without an entry their cards could report every measurement type fresh while the workout leg had been silent for weeks. And the status pill drops a generic `aria-label` that replaced its own accessible name, so a screen reader announced the same words for a healthy connection as for a broken one.
This commit is contained in:
1 parent
06935256b0
commit
4e95df9e8d
7 files changed
+507
-16
No files matched your search
@@ -219,6 +219,8 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
|
||||
type Body = {
|
||||
lastSyncedAt: string | null;
|
||||
lastSyncTrigger: string | null;
|
||||
lastBackgroundSyncAt: string | null;
|
||||
syncHealth: { verdict: string; since: string | null };
|
||||
metricFreshness: Array<{
|
||||
type: string;
|
||||
@@ -293,6 +295,38 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
expect(byType.PULSE).toBe(false);
|
||||
});
|
||||
|
||||
// #586 — the reporter's question is whether the phone delivers on its own or
|
||||
// only while the app is open. `lastSyncedAt` reads identically either way, so
|
||||
// it cannot answer it; these two fields are what can.
|
||||
it("publishes the trigger of the last batch and the last background arrival", async () => {
|
||||
const backgroundAt = ago(3 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
healthKitLastSyncTrigger: "background",
|
||||
healthKitLastBackgroundSyncAt: backgroundAt,
|
||||
} as never);
|
||||
|
||||
const body = await read();
|
||||
expect(body.lastSyncTrigger).toBe("background");
|
||||
expect(body.lastBackgroundSyncAt).toBe(backgroundAt.toISOString());
|
||||
});
|
||||
|
||||
it("reports honest absence when the client has never declared a trigger", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
healthKitLastSyncTrigger: null,
|
||||
healthKitLastBackgroundSyncAt: null,
|
||||
} as never);
|
||||
|
||||
const body = await read();
|
||||
// An older client that reports nothing, and a pipe that has never
|
||||
// delivered in the background, both read as null — never as a guess.
|
||||
expect(body.lastSyncTrigger).toBeNull();
|
||||
expect(body.lastBackgroundSyncAt).toBeNull();
|
||||
});
|
||||
|
||||
it("degrades to no per-metric data rather than failing the config read", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* batch ingest.
|
||||
*
|
||||
* It is DIAGNOSTIC-ONLY: recorded on the `measurement.batch.ingest` wide event
|
||||
* so an operator can see, per batch, what woke the client (foreground app open,
|
||||
* background refresh, or a push), and NOTHING else. These pin that contract:
|
||||
* and kept on the account, where the Apple Health card reads it. These pin that
|
||||
* contract:
|
||||
*
|
||||
* - a batch carrying `syncTrigger:"background"` is accepted and the value
|
||||
* rides the ingest annotation;
|
||||
@@ -12,7 +12,11 @@
|
||||
* - an invalid value is a 422 (closed enum);
|
||||
* - the field never changes which rows are inserted / updated — the per-row
|
||||
* outcomes are byte-identical with and without it, so it cannot leak into
|
||||
* dedup, attribution, or storage.
|
||||
* dedup, attribution, or storage;
|
||||
* - the trigger is stamped on the account, and a background or push trigger
|
||||
* additionally dates the last background arrival. That second timestamp is
|
||||
* the whole diagnostic (#586): `healthKitLastSyncedAt` alone reads the same
|
||||
* whether the phone delivers on its own or only while the app is open.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -301,3 +305,68 @@ describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", ()
|
||||
expect(withoutBody.inserted).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — HealthKit trigger persistence (#586)", () => {
|
||||
function stampedData() {
|
||||
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as
|
||||
{ data: Record<string, unknown> } | undefined;
|
||||
return call?.data;
|
||||
}
|
||||
|
||||
it("dates the last background arrival when the batch says it woke in the background", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [sampleEntry(SAMPLE_ID, 64)],
|
||||
syncTrigger: "background",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(stampedData()).toMatchObject({
|
||||
healthKitLastSyncedAt: expect.any(Date),
|
||||
healthKitLastSyncTrigger: "background",
|
||||
healthKitLastBackgroundSyncAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a push wake as delivery without the app too", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [sampleEntry(SAMPLE_ID, 64)],
|
||||
syncTrigger: "push",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(stampedData()).toMatchObject({
|
||||
healthKitLastSyncTrigger: "push",
|
||||
healthKitLastBackgroundSyncAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the background timestamp untouched for a foreground batch", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [sampleEntry(SAMPLE_ID, 64)],
|
||||
syncTrigger: "foreground",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = stampedData();
|
||||
expect(data).toMatchObject({ healthKitLastSyncTrigger: "foreground" });
|
||||
// Not written at all — a sync the user triggered by opening the app is no
|
||||
// evidence about background delivery, and overwriting the older honest
|
||||
// timestamp with this one would erase the only answer the card has.
|
||||
expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt");
|
||||
});
|
||||
|
||||
it("writes null over a stale trigger when the client declares none", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [sampleEntry(SAMPLE_ID, 64)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = stampedData();
|
||||
// The field describes THIS sync. Leaving the previous batch's trigger
|
||||
// standing would attribute it to a batch that never declared one.
|
||||
expect(data).toMatchObject({ healthKitLastSyncTrigger: null });
|
||||
expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user