mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Tell a first-run what Apple Health is actually doing
Refs #778. Two honesty gaps from the first-run report: the web onboarding presented Apple Health like a web connector although the connection only completes in the iPhone app, and the initial backfill gave no sign of progress, so a working first sync looked broken. The onboarding source step now says plainly that Apple Health is set up in the iPhone app after onboarding, without dead-ending the flow. The integration card gains a server-authoritative progress summary — rows accepted, oldest reading reached, and an honest flowing/waiting line derived from the last accepted batch. Nothing the server does not know is shown: no percentage, no ETA, no invented throttle state; a quiet stretch reads as waiting for the phone, not as an error.
This commit is contained in:
1 parent
b1be67bfa7
commit
a3561b4600
10 files changed
+486
-4
No files matched your search
@@ -6,9 +6,10 @@ vi.mock("@/lib/db", () => ({
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
|
||||
auditLog: { create: vi.fn() },
|
||||
// Per-metric freshness for the APPLE_HEALTH source.
|
||||
measurement: { groupBy: vi.fn() },
|
||||
workout: { groupBy: vi.fn() },
|
||||
// Per-metric freshness + first-run sync progress for the APPLE_HEALTH
|
||||
// source (#778).
|
||||
measurement: { groupBy: vi.fn(), aggregate: vi.fn() },
|
||||
workout: { groupBy: vi.fn(), aggregate: vi.fn() },
|
||||
},
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
@@ -54,6 +55,14 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.aggregate).mockResolvedValue({
|
||||
_count: { _all: 0 },
|
||||
_min: { measuredAt: null },
|
||||
} as never);
|
||||
vi.mocked(prisma.workout.aggregate).mockResolvedValue({
|
||||
_count: { _all: 0 },
|
||||
_min: { startedAt: null },
|
||||
} as never);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
@@ -227,6 +236,10 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
lastSeenAt: string;
|
||||
stale: boolean;
|
||||
}>;
|
||||
syncProgress: {
|
||||
recordsAccepted: number;
|
||||
oldestMeasuredAt: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
async function read(): Promise<Body> {
|
||||
@@ -327,6 +340,43 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
expect(body.lastBackgroundSyncAt).toBeNull();
|
||||
});
|
||||
|
||||
// #778 — a first-run backfill was invisible from the web: nothing said how
|
||||
// many records had arrived or how far back they reached. The GET now carries
|
||||
// the two figures the server actually holds.
|
||||
it("publishes the accepted-row count and the oldest instant reached", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.aggregate).mockResolvedValue({
|
||||
_count: { _all: 4200 },
|
||||
_min: { measuredAt: new Date("2019-05-04T06:00:00.000Z") },
|
||||
} as never);
|
||||
vi.mocked(prisma.workout.aggregate).mockResolvedValue({
|
||||
_count: { _all: 12 },
|
||||
_min: { startedAt: new Date("2021-01-01T09:00:00.000Z") },
|
||||
} as never);
|
||||
|
||||
const body = await read();
|
||||
expect(body.syncProgress).toEqual({
|
||||
recordsAccepted: 4212,
|
||||
oldestMeasuredAt: "2019-05-04T06:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("degrades to a null progress summary rather than failing the config read", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(DAY),
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.aggregate).mockRejectedValue(
|
||||
new Error("aggregate hiccup"),
|
||||
);
|
||||
const body = await read();
|
||||
expect(body.syncProgress).toBeNull();
|
||||
expect(body.syncHealth.verdict).toBe("fresh");
|
||||
});
|
||||
|
||||
it("degrades to no per-metric data rather than failing the config read", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
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 { getSourceFreshness } from "@/lib/integrations/metric-freshness";
|
||||
import {
|
||||
classifyMetricFreshness,
|
||||
@@ -168,6 +169,16 @@ export const GET = apiHandler(async () => {
|
||||
const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch(
|
||||
() => [],
|
||||
);
|
||||
// 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
|
||||
// instant) are everything the server actually knows about backfill
|
||||
// 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,
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
entries,
|
||||
@@ -176,6 +187,7 @@ export const GET = apiHandler(async () => {
|
||||
lastBackgroundSyncAt,
|
||||
syncHealth,
|
||||
metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict),
|
||||
syncProgress,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user