mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -243,6 +243,9 @@ test.describe("Apple Health guidance", () => {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
metricFreshness: [],
|
||||
// #778 — nothing accepted yet, so the card must show no
|
||||
// progress section for this pending-first-sync account.
|
||||
syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null },
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -112,3 +112,23 @@ describe("<SourceCardGrid> Withings card", () => {
|
||||
expect(html).not.toContain("Connect Withings");
|
||||
});
|
||||
});
|
||||
|
||||
// #778 — the step listed Apple Health next to the web connectors with nothing
|
||||
// saying its setup happens in the iOS app afterwards, so it read like one more
|
||||
// OAuth flow to hunt for.
|
||||
describe("<SourceCardGrid> Apple Health note", () => {
|
||||
it("says Apple Health setup completes in the iPhone app, not on the web", () => {
|
||||
integrationStatusState.data = undefined;
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="source-more-apple-health-note"');
|
||||
expect(html).toContain("open the HealthLog app on your iPhone");
|
||||
expect(html).toContain("no web connection");
|
||||
});
|
||||
|
||||
it("keeps the Apple Health chip tappable so the note is not a dead end", () => {
|
||||
integrationStatusState.data = undefined;
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="source-more-apple-health"');
|
||||
expect(html).toContain('href="/settings/integrations"');
|
||||
});
|
||||
});
|
||||
@@ -222,6 +222,18 @@ export function SourceCardGrid() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* #778 — Apple Health looked like one more web connector here, but
|
||||
there is nothing to connect on the web: the sync is set up in the
|
||||
iOS app after onboarding. Say so, so nobody hunts Settings for an
|
||||
OAuth flow that does not exist. The chip stays tappable (it lands
|
||||
on the Integrations page, whose Apple Health card walks through
|
||||
the iPhone-app setup) and Next / Skip advance as always. */}
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-relaxed"
|
||||
data-testid="source-more-apple-health-note"
|
||||
>
|
||||
{t("onboarding.source.more.appleHealthHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -20,6 +20,10 @@ type Payload = {
|
||||
lastSeenAt: string;
|
||||
stale: boolean;
|
||||
}>;
|
||||
syncProgress?: {
|
||||
recordsAccepted: number;
|
||||
oldestMeasuredAt: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
let statusPayload: Payload | undefined;
|
||||
@@ -295,3 +299,108 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
|
||||
expect(html).not.toContain("Last background sync");
|
||||
});
|
||||
});
|
||||
|
||||
// #778 — the first-run backfill used to be invisible: a user watching the web
|
||||
// app had no way to tell "still flowing" from "looks dead but is working".
|
||||
describe("<AppleHealthCard> — sync progress (#778)", () => {
|
||||
beforeEach(() => {
|
||||
statusPayload = undefined;
|
||||
statusLoading = false;
|
||||
statusError = false;
|
||||
});
|
||||
|
||||
it("shows the accepted-row count and the oldest reading reached", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
syncProgress: {
|
||||
recordsAccepted: 12345,
|
||||
oldestMeasuredAt: "2019-05-04T06:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-progress"');
|
||||
expect(html).toContain('data-testid="apple-health-progress-received"');
|
||||
// Locale-grouped integer, so the count reads as a number, not a code.
|
||||
expect(html).toContain("12,345");
|
||||
expect(html).toContain('data-testid="apple-health-progress-oldest"');
|
||||
// The 2019 date renders with its year — the whole point is showing how
|
||||
// far back the backfill has reached.
|
||||
expect(html).toContain("2019");
|
||||
});
|
||||
|
||||
it("reads as flowing while batches keep arriving", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-progress-state"');
|
||||
expect(html).toContain('data-state="flowing"');
|
||||
expect(html).toContain("still delivering");
|
||||
});
|
||||
|
||||
it("waits honestly instead of inventing throttle state when batches pause", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="waiting"');
|
||||
// The server does not track the phone's throttle/queue state, so the copy
|
||||
// says "waiting for the iPhone app", never a fabricated percentage or ETA.
|
||||
expect(html).toContain("Waiting for the iPhone app to send more data");
|
||||
expect(html).not.toContain("%");
|
||||
});
|
||||
|
||||
it("renders no progress section before anything has arrived", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: null,
|
||||
syncHealth: { verdict: "pending_first_sync", since: null },
|
||||
syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null },
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
// The delivery section's "no sync yet" line already owns the blank state.
|
||||
expect(html).not.toContain('data-testid="apple-health-progress"');
|
||||
});
|
||||
|
||||
it("renders no progress section when the server read failed", () => {
|
||||
statusPayload = {
|
||||
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
syncHealth: { verdict: "fresh", since: null },
|
||||
syncProgress: null,
|
||||
};
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).not.toContain('data-testid="apple-health-progress"');
|
||||
});
|
||||
|
||||
it("defines every progress key in all six locale catalogs", () => {
|
||||
const requiredKeys = [
|
||||
"title",
|
||||
"receivedLabel",
|
||||
"oldestLabel",
|
||||
"flowing",
|
||||
"waiting",
|
||||
] as const;
|
||||
|
||||
for (const catalog of [en, de, es, fr, itMessages, pl]) {
|
||||
const progress = catalog.settings.appleHealth.progress;
|
||||
for (const key of requiredKeys) {
|
||||
expect(progress[key]).toBeTypeOf("string");
|
||||
expect(progress[key].length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "@/components/settings/integration-status-pill";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
MetricFreshnessEntry,
|
||||
SyncHealth,
|
||||
@@ -44,8 +44,23 @@ interface HealthKitStatus {
|
||||
lastBackgroundSyncAt?: string | null;
|
||||
syncHealth?: SyncHealth;
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
/** Server-side backfill progress (#778); null when the read failed. */
|
||||
syncProgress?: {
|
||||
recordsAccepted: number;
|
||||
oldestMeasuredAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long after the last accepted batch the progress line stops claiming
|
||||
* data is flowing. The server tracks no device-side throttle or queue state,
|
||||
* so the only honest signal is the age of the newest batch: within the hour
|
||||
* reads as "still arriving", beyond it as "waiting for the iPhone app" — a
|
||||
* backfill that iOS has parked and a backfill that finished look identical
|
||||
* from here, and the copy says neither is an error.
|
||||
*/
|
||||
const PROGRESS_WAITING_AFTER_MS = 60 * 60 * 1000;
|
||||
|
||||
const TRIGGER_KEYS: Record<SyncTrigger, string> = {
|
||||
foreground: "settings.appleHealth.delivery.trigger.foreground",
|
||||
background: "settings.appleHealth.delivery.trigger.background",
|
||||
@@ -119,6 +134,79 @@ function DeliveryDiagnostic({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run backfill progress (#778). During the initial HealthKit backfill
|
||||
* the card said nothing about how much had arrived or how far back it
|
||||
* reached, so a working first run looked broken. This renders the figures
|
||||
* the server actually holds — accepted-row count and the oldest instant
|
||||
* reached — plus one honest state line derived from the age of the newest
|
||||
* batch. No ETA and no percentage: the remaining queue lives on the phone
|
||||
* and the server cannot see it.
|
||||
*/
|
||||
function SyncProgress({
|
||||
status,
|
||||
t,
|
||||
now,
|
||||
}: {
|
||||
status: HealthKitStatus;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
/** Injectable reference instant, mirroring the pill's own prop. */
|
||||
now?: Date;
|
||||
}) {
|
||||
const formatters = useFormatters();
|
||||
const progress = status.syncProgress;
|
||||
// Nothing accepted and nothing synced: the delivery section's "no sync yet"
|
||||
// line already covers the blank state — a zero-row table would only repeat it.
|
||||
if (!progress || (progress.recordsAccepted <= 0 && !status.lastSyncedAt)) {
|
||||
return null;
|
||||
}
|
||||
const reference = now ?? new Date();
|
||||
const lastBatchAge = status.lastSyncedAt
|
||||
? Math.max(0, reference.getTime() - new Date(status.lastSyncedAt).getTime())
|
||||
: null;
|
||||
const flowing =
|
||||
lastBatchAge !== null && lastBatchAge < PROGRESS_WAITING_AFTER_MS;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="apple-health-progress">
|
||||
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("settings.appleHealth.progress.title")}
|
||||
</h3>
|
||||
<dl className="space-y-1 text-sm">
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="text-muted-foreground">
|
||||
{t("settings.appleHealth.progress.receivedLabel")}
|
||||
</dt>
|
||||
<dd data-testid="apple-health-progress-received">
|
||||
{formatters.integer(progress.recordsAccepted)}
|
||||
</dd>
|
||||
</div>
|
||||
{progress.oldestMeasuredAt ? (
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="text-muted-foreground">
|
||||
{t("settings.appleHealth.progress.oldestLabel")}
|
||||
</dt>
|
||||
<dd data-testid="apple-health-progress-oldest">
|
||||
{formatters.date(new Date(progress.oldestMeasuredAt))}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
{lastBatchAge !== null ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="apple-health-progress-state"
|
||||
data-state={flowing ? "flowing" : "waiting"}
|
||||
>
|
||||
{flowing
|
||||
? t("settings.appleHealth.progress.flowing")
|
||||
: t("settings.appleHealth.progress.waiting")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const {
|
||||
@@ -175,6 +263,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
|
||||
|
||||
{showPill && <DeliveryDiagnostic status={status} t={t} />}
|
||||
|
||||
{showPill && <SyncProgress status={status} t={t} />}
|
||||
|
||||
<MetricFreshnessDisclosure
|
||||
entries={status?.metricFreshness}
|
||||
idPrefix="apple-health"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { aggregate: vi.fn() },
|
||||
workout: { aggregate: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { getAppleHealthSyncProgress } from "../apple-health-progress";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function mockAggregates(
|
||||
measurement: { count: number; min: Date | null },
|
||||
workout: { count: number; min: Date | null },
|
||||
) {
|
||||
vi.mocked(prisma.measurement.aggregate).mockResolvedValue({
|
||||
_count: { _all: measurement.count },
|
||||
_min: { measuredAt: measurement.min },
|
||||
} as never);
|
||||
vi.mocked(prisma.workout.aggregate).mockResolvedValue({
|
||||
_count: { _all: workout.count },
|
||||
_min: { startedAt: workout.min },
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("getAppleHealthSyncProgress", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("sums measurement and workout rows into one accepted count", async () => {
|
||||
mockAggregates(
|
||||
{ count: 1200, min: new Date("2019-03-01T08:00:00.000Z") },
|
||||
{ count: 45, min: new Date("2021-06-01T06:30:00.000Z") },
|
||||
);
|
||||
|
||||
const progress = await getAppleHealthSyncProgress("user-1");
|
||||
|
||||
expect(progress.recordsAccepted).toBe(1245);
|
||||
});
|
||||
|
||||
it("reports the oldest instant across both tables", async () => {
|
||||
mockAggregates(
|
||||
{ count: 10, min: new Date("2020-01-05T00:00:00.000Z") },
|
||||
{ count: 3, min: new Date("2018-11-20T07:15:00.000Z") },
|
||||
);
|
||||
|
||||
const progress = await getAppleHealthSyncProgress("user-1");
|
||||
|
||||
expect(progress.oldestMeasuredAt).toBe("2018-11-20T07:15:00.000Z");
|
||||
});
|
||||
|
||||
it("uses the measurement minimum when workouts have no rows", async () => {
|
||||
mockAggregates(
|
||||
{ count: 8, min: new Date("2022-02-02T12:00:00.000Z") },
|
||||
{
|
||||
count: 0,
|
||||
min: null,
|
||||
},
|
||||
);
|
||||
|
||||
const progress = await getAppleHealthSyncProgress("user-1");
|
||||
|
||||
expect(progress.recordsAccepted).toBe(8);
|
||||
expect(progress.oldestMeasuredAt).toBe("2022-02-02T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("is honest about a source that has delivered nothing", async () => {
|
||||
mockAggregates({ count: 0, min: null }, { count: 0, min: null });
|
||||
|
||||
const progress = await getAppleHealthSyncProgress("user-1");
|
||||
|
||||
expect(progress).toEqual({ recordsAccepted: 0, oldestMeasuredAt: null });
|
||||
});
|
||||
|
||||
it("scopes both reads to the user and the APPLE_HEALTH source", async () => {
|
||||
mockAggregates({ count: 0, min: null }, { count: 0, min: null });
|
||||
|
||||
await getAppleHealthSyncProgress("user-9");
|
||||
|
||||
expect(prisma.measurement.aggregate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-9", deletedAt: null, source: "APPLE_HEALTH" },
|
||||
}),
|
||||
);
|
||||
expect(prisma.workout.aggregate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "user-9", source: "APPLE_HEALTH" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* First-run Apple Health sync progress (issue #778).
|
||||
*
|
||||
* The iOS app drives the HealthKit backfill; the server only ever sees the
|
||||
* batches that actually arrive. During a first run nothing on the web said
|
||||
* how far that had come, so a backfill that was quietly working looked
|
||||
* broken. This module computes the figures the server genuinely holds — how
|
||||
* many rows carry the `APPLE_HEALTH` source and how far back in time they
|
||||
* reach — and nothing more: the phone's throttle state, its remaining queue,
|
||||
* and any ETA live on the device and are deliberately not invented here.
|
||||
*
|
||||
* Both the live batch ingest and the one-shot export.zip import write
|
||||
* `source = APPLE_HEALTH`, so the count is "Apple Health records in
|
||||
* HealthLog", not "records from one specific pipe" — which is the number a
|
||||
* user watching a first run actually wants to see grow.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export interface AppleHealthSyncProgress {
|
||||
/** Measurement + workout rows carrying the `APPLE_HEALTH` source. */
|
||||
recordsAccepted: number;
|
||||
/**
|
||||
* Earliest instant reached (`measuredAt` for measurements, `startedAt` for
|
||||
* workouts), as an ISO string; null when nothing has arrived.
|
||||
*/
|
||||
oldestMeasuredAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two grouped aggregates (count + MIN), mirroring the freshness reads in
|
||||
* `./metric-freshness.ts` which cover the same two tables for the same
|
||||
* reason: workouts are Apple Health data too, but never `Measurement` rows.
|
||||
*/
|
||||
export async function getAppleHealthSyncProgress(
|
||||
userId: string,
|
||||
): Promise<AppleHealthSyncProgress> {
|
||||
const [measurements, workouts] = await Promise.all([
|
||||
prisma.measurement.aggregate({
|
||||
where: { userId, deletedAt: null, source: "APPLE_HEALTH" },
|
||||
_count: { _all: true },
|
||||
_min: { measuredAt: true },
|
||||
}),
|
||||
// `Workout` rows are hard-deleted, so there is no tombstone predicate.
|
||||
prisma.workout.aggregate({
|
||||
where: { userId, source: "APPLE_HEALTH" },
|
||||
_count: { _all: true },
|
||||
_min: { startedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const minima = [measurements._min.measuredAt, workouts._min.startedAt].filter(
|
||||
(d): d is Date => d instanceof Date,
|
||||
);
|
||||
const oldest =
|
||||
minima.length > 0
|
||||
? new Date(Math.min(...minima.map((d) => d.getTime())))
|
||||
: null;
|
||||
|
||||
return {
|
||||
recordsAccepted: measurements._count._all + workouts._count._all,
|
||||
oldestMeasuredAt: oldest?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
@@ -85,6 +85,30 @@ const metricFreshnessEntry = z
|
||||
"Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row.",
|
||||
});
|
||||
|
||||
// Issue #778 — the two backfill-progress figures the server genuinely holds.
|
||||
// The iOS app drives the backfill; its queue, throttle state, and any ETA
|
||||
// live on the device and are deliberately absent here.
|
||||
const appleHealthSyncProgress = z
|
||||
.object({
|
||||
recordsAccepted: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Measurement + workout rows carrying the `APPLE_HEALTH` source (live batch ingest and the one-shot export import both write it). Soft-deleted measurement rows are excluded.",
|
||||
),
|
||||
oldestMeasuredAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe(
|
||||
"Earliest instant reached across those rows (`measuredAt` for measurements, `startedAt` for workouts); null when nothing has arrived. During a first-run backfill this walks backwards as history lands.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AppleHealthSyncProgress",
|
||||
description:
|
||||
"Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they reach. Only what the server actually knows — no ETA, no device-side queue state.",
|
||||
});
|
||||
|
||||
const healthKitConfigResponse = z
|
||||
.object({
|
||||
entries: z.array(healthKitEntry),
|
||||
@@ -116,6 +140,12 @@ const healthKitConfigResponse = z
|
||||
.describe(
|
||||
"Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.",
|
||||
),
|
||||
syncProgress: appleHealthSyncProgress
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "HealthKitConfigResponse",
|
||||
|
||||
Reference in new issue
Block a user