Files
HealthLog/src/lib/integrations/apple-health-progress.ts
T
Marc-André Bombeck a3561b4600 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.
2026-08-14 09:46:24 +02:00

64 lines
2.4 KiB
TypeScript

/**
* 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,
};
}