mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(healthkit): record Apple Health dose arrivals and the last background arrival per type
Migration 0401 turns healthkit_type_syncs.type into text so the ledger can hold MEDICATION_DOSE, and adds last_background_at (seeded from rows whose latest arrival was a background one). POST /api/medications/intake/bulk takes syncTrigger and records APPLE_HEALTH dose arrivals; the Apple Health status read serves a MEDICATION_DOSE freshness entry and lastBackgroundAt on every entry with arrival facts.
This commit is contained in:
1 parent
6313c0174a
commit
03b24461ad
22 files changed
+352
-54
No files matched your search
@@ -76,6 +76,19 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
/**
|
||||
* The route issues two raw reads: the newest-sample probe per measurement type
|
||||
* and (v1.42.4) the newest Apple Health dose. Answer each by its SQL.
|
||||
*/
|
||||
function mockProbes(rows: unknown, doseLastSeen: Date | null = null): void {
|
||||
vi.mocked(prisma.$queryRaw).mockImplementation((async (
|
||||
strings: TemplateStringsArray,
|
||||
) =>
|
||||
strings.join("?").includes("medication_intake_events")
|
||||
? [{ last_seen: doseLastSeen }]
|
||||
: rows) as never);
|
||||
}
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeGetReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/integrations/healthkit");
|
||||
@@ -313,9 +326,7 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
healthKitLastSyncedAt: null,
|
||||
} as never);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ type: "PULSE", last_seen: ago(DAY) },
|
||||
] as never);
|
||||
mockProbes([{ 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]);
|
||||
@@ -330,7 +341,7 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
"RESPIRATORY_RATE",
|
||||
"PULSE",
|
||||
]);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
mockProbes([
|
||||
{ type: "RESPIRATORY_RATE", last_seen: ago(30 * DAY) },
|
||||
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
|
||||
] as never);
|
||||
@@ -441,7 +452,7 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
"RESPIRATORY_RATE",
|
||||
"PULSE",
|
||||
]);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
mockProbes([
|
||||
{ type: "RESPIRATORY_RATE", last_seen: ago(2 * DAY) },
|
||||
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
|
||||
] as never);
|
||||
@@ -476,15 +487,74 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
expect(byType.WORKOUTS).not.toHaveProperty("lastReceivedAt");
|
||||
});
|
||||
|
||||
// v1.42.4 — Apple Health doses join the list with their own arrival facts,
|
||||
// and every entry carries the last background arrival beside the last one.
|
||||
it("serves a MEDICATION_DOSE entry and the last background arrival", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
|
||||
const doseAt = ago(40 * DAY);
|
||||
mockProbes([{ type: "PULSE", last_seen: ago(60 * 60 * 1000) }], doseAt);
|
||||
const receivedAt = ago(60 * 60 * 1000);
|
||||
const backgroundAt = ago(5 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "MEDICATION_DOSE",
|
||||
lastReceivedAt: receivedAt,
|
||||
lastTrigger: "foreground",
|
||||
lastNewSampleAt: null,
|
||||
lastBackgroundAt: backgroundAt,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
lastReceivedAt: receivedAt,
|
||||
lastTrigger: "background",
|
||||
lastNewSampleAt: receivedAt,
|
||||
lastBackgroundAt: null,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const body = await read();
|
||||
expect(body.metricFreshness.map((e) => e.type)).toEqual([
|
||||
"MEDICATION_DOSE",
|
||||
"PULSE",
|
||||
]);
|
||||
const byType = Object.fromEntries(
|
||||
body.metricFreshness.map((entry) => [entry.type, entry]),
|
||||
);
|
||||
expect(byType.MEDICATION_DOSE).toEqual({
|
||||
type: "MEDICATION_DOSE",
|
||||
lastSeenAt: doseAt.toISOString(),
|
||||
// Forty days without a dose is not a dead pipe: as-needed medications.
|
||||
stale: false,
|
||||
lastReceivedAt: receivedAt.toISOString(),
|
||||
lastTrigger: "foreground",
|
||||
lastNewSampleAt: null,
|
||||
lastBackgroundAt: backgroundAt.toISOString(),
|
||||
});
|
||||
expect(byType.PULSE).toMatchObject({ lastBackgroundAt: null });
|
||||
});
|
||||
|
||||
it("leaves the dose entry out when there is no Apple Health dose", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(DAY),
|
||||
} as never);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
|
||||
mockProbes([{ type: "PULSE", last_seen: ago(DAY) }], null);
|
||||
const body = await read();
|
||||
expect(body.metricFreshness.map((e) => e.type)).toEqual(["PULSE"]);
|
||||
});
|
||||
|
||||
it("leaves the arrival facts off when the ledger read fails", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(DAY),
|
||||
} as never);
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ type: "PULSE", last_seen: ago(DAY) },
|
||||
] as never);
|
||||
mockProbes([{ type: "PULSE", last_seen: ago(DAY) }] as never);
|
||||
vi.mocked(prisma.healthKitTypeSync.findMany).mockRejectedValue(
|
||||
new Error("ledger hiccup"),
|
||||
);
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
newestAppleHealthDataAt,
|
||||
withHealthKitArrivals,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import { getSourceFreshness } from "@/lib/integrations/metric-freshness";
|
||||
import {
|
||||
getAppleHealthDoseFreshness,
|
||||
getSourceFreshness,
|
||||
} from "@/lib/integrations/metric-freshness";
|
||||
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
import {
|
||||
classifyMetricFreshness,
|
||||
@@ -177,12 +180,22 @@ export const GET = apiHandler(async () => {
|
||||
// `health` is the unified connection state; fail-soft to null for the same
|
||||
// reason as the reads beside it, so the config the phone needs never 500s
|
||||
// over a diagnostic.
|
||||
const [samples, arrivals, syncProgress, health] = await Promise.all([
|
||||
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
|
||||
getHealthKitTypeArrivals(user.id).catch(() => null),
|
||||
getAppleHealthSyncProgress(user.id).catch(() => null),
|
||||
loadOneConnectionHealth(user.id, "apple-health").catch(() => null),
|
||||
]);
|
||||
// v1.42.4 — Apple Health doses join the list as `MEDICATION_DOSE`, so the
|
||||
// phone can read when doses last arrived from the same entries as every
|
||||
// other type. Fail-soft on its own: a failed dose read drops that one entry.
|
||||
const [measurementSamples, doseSample, arrivals, syncProgress, health] =
|
||||
await Promise.all([
|
||||
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
|
||||
getAppleHealthDoseFreshness(user.id).catch(() => null),
|
||||
getHealthKitTypeArrivals(user.id).catch(() => null),
|
||||
getAppleHealthSyncProgress(user.id).catch(() => null),
|
||||
loadOneConnectionHealth(user.id, "apple-health").catch(() => null),
|
||||
]);
|
||||
const samples = doseSample
|
||||
? [...measurementSamples, doseSample].sort((a, b) =>
|
||||
a.type.localeCompare(b.type),
|
||||
)
|
||||
: measurementSamples;
|
||||
// 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
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
findFoldedWindowDuplicates,
|
||||
} from "@/lib/measurements/folded-window";
|
||||
import {
|
||||
HEALTHKIT_SYNC_TRIGGERS,
|
||||
recordHealthKitTypeArrivals,
|
||||
type HealthKitTypeArrival,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
@@ -198,7 +199,7 @@ const batchEntrySchema = z.object({
|
||||
// pre-#66 caller stays byte-for-byte unchanged.
|
||||
// v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to
|
||||
// the app simply being open (`foreground`).
|
||||
const syncTriggerEnum = z.enum(["foreground", "background", "push", "manual"]);
|
||||
const syncTriggerEnum = z.enum(HEALTHKIT_SYNC_TRIGGERS);
|
||||
|
||||
const batchPayloadSchema = z.object({
|
||||
entries: z.array(batchEntrySchema).min(1),
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
* per Apple dose)
|
||||
* }
|
||||
*
|
||||
* Top level also takes `syncTrigger?` (foreground | background | push |
|
||||
* manual, v1.42.4), diagnostic only: it dates the `MEDICATION_DOSE` entry of
|
||||
* the Apple Health per-type arrival record and changes no row.
|
||||
*
|
||||
* Response (always 200): processed / inserted / duplicates / skipped /
|
||||
* entries[] mirroring the measurements + workouts batch envelope.
|
||||
*
|
||||
@@ -53,6 +57,11 @@ import {
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
|
||||
import {
|
||||
HEALTHKIT_SYNC_TRIGGERS,
|
||||
MEDICATION_DOSE_ARRIVAL_TYPE,
|
||||
recordHealthKitTypeArrivals,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
@@ -164,6 +173,10 @@ const bulkEntrySchema = z
|
||||
|
||||
const bulkPayloadSchema = z.object({
|
||||
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
|
||||
// v1.42.4 — what woke the phone for this sync, the same diagnostic-only
|
||||
// field `POST /api/measurements/batch` takes. It changes no row; it only
|
||||
// feeds the `MEDICATION_DOSE` arrival record below.
|
||||
syncTrigger: z.enum(HEALTHKIT_SYNC_TRIGGERS).optional(),
|
||||
});
|
||||
|
||||
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
|
||||
@@ -243,7 +256,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
const { entries } = parsed.data;
|
||||
const { entries, syncTrigger } = parsed.data;
|
||||
|
||||
// Up-front ownership check — the user can only POST intake events
|
||||
// against medications they own. One read covers every medicationId
|
||||
@@ -992,6 +1005,28 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
})();
|
||||
}
|
||||
|
||||
// v1.42.4 — Apple Health doses get the per-type arrival record the
|
||||
// measurement batch keeps for its types: when they last arrived, under which
|
||||
// trigger, when a sync last brought a new or changed dose, and when a
|
||||
// background sync last carried them. Entries sent with `source:
|
||||
// "APPLE_HEALTH"` only; a dose taken in the app is not an Apple Health
|
||||
// arrival. A replay answered `duplicate` still counts as an arrival, the
|
||||
// same rule as the batch route.
|
||||
const doseArrival = { accepted: 0, newSamples: 0 };
|
||||
for (const result of results) {
|
||||
if (entries[result.index]?.source !== "APPLE_HEALTH") continue;
|
||||
if (result.status === "skipped") continue;
|
||||
doseArrival.accepted += 1;
|
||||
if (result.status !== "duplicate") doseArrival.newSamples += 1;
|
||||
}
|
||||
if (doseArrival.accepted > 0) {
|
||||
await recordHealthKitTypeArrivals(prisma, {
|
||||
userId: user.id,
|
||||
counts: new Map([[MEDICATION_DOSE_ARRIVAL_TYPE, doseArrival]]),
|
||||
trigger: syncTrigger ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// The SyncMode checkpoint belongs to the push that moved the data, not to
|
||||
// the `/api/sync/state` handshake that reports it. This endpoint is one of
|
||||
// the three domains that handshake summarises, so an intake drain advances
|
||||
|
||||
@@ -133,7 +133,8 @@ function DeliveryDiagnostic({
|
||||
* under which trigger, and when one last brought a new value. The newest
|
||||
* sample's time on the right of the row says when the reading was TAKEN; this
|
||||
* says when the server RECEIVED it, which is what tells a type that only
|
||||
* arrives on "Sync all" from one the phone delivers by itself (#1173). Nothing
|
||||
* arrives on "Sync all" from one the phone delivers by itself (#1173), plus
|
||||
* the last background arrival when a later one came another way. Nothing
|
||||
* renders when the server sent no arrival facts (the workouts row, or a failed
|
||||
* ledger read), so absence is never painted as "not received".
|
||||
*/
|
||||
@@ -176,6 +177,18 @@ export function ArrivalDetail({
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The last background arrival, when a later one came another way: opening
|
||||
// the app to look would otherwise hide that the phone delivers by itself.
|
||||
if (
|
||||
entry.lastBackgroundAt &&
|
||||
entry.lastBackgroundAt !== entry.lastReceivedAt
|
||||
) {
|
||||
parts.push(
|
||||
t("settings.appleHealth.freshness.lastBackground", {
|
||||
relative: relative(entry.lastBackgroundAt),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid="apple-health-arrival"
|
||||
|
||||
@@ -30,6 +30,8 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
/** The pseudo-type carrying a source's newest workout. */
|
||||
const WORKOUT_TYPE = "WORKOUTS";
|
||||
/** The pseudo-type carrying Apple Health medication doses. */
|
||||
const MEDICATION_DOSE_TYPE = "MEDICATION_DOSE";
|
||||
|
||||
/**
|
||||
* Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS`
|
||||
@@ -68,6 +70,8 @@ export function MetricFreshnessList<
|
||||
const label = (type: string): string => {
|
||||
if (type === WORKOUT_TYPE)
|
||||
return t("settings.integrationFreshness.workouts");
|
||||
if (type === MEDICATION_DOSE_TYPE)
|
||||
return t("settings.integrationFreshness.medicationDoses");
|
||||
const key = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
return key ? t(key) : humanise(type);
|
||||
};
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
*
|
||||
* The batch route records, per measurement type it received, when the type
|
||||
* last arrived, under which `syncTrigger`, and when it last brought a new or
|
||||
* updated sample (as opposed to duplicates only). The Apple Health card reads
|
||||
* it to show which types are actually flowing. A scoped ingest credential
|
||||
* does not write it, the same rule as `lastSyncedAt`.
|
||||
* updated sample (as opposed to duplicates only). Since v1.42.4 the intake
|
||||
* bulk route records Apple Health doses the same way under the key
|
||||
* `MEDICATION_DOSE`, and every row also keeps when a `background` batch last
|
||||
* carried the type. The Apple Health card reads it to show which types are
|
||||
* actually flowing. A scoped ingest credential does not write it, the same
|
||||
* rule as `lastSyncedAt`.
|
||||
*
|
||||
* Sample time (`max(measuredAt)`, what the freshness list showed before) and
|
||||
* arrival time are different questions. A type the phone only hands over
|
||||
@@ -17,19 +20,32 @@ import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logCaught } from "@/lib/logging/signal";
|
||||
import {
|
||||
MEDICATION_DOSE_FRESHNESS_TYPE,
|
||||
WORKOUT_FRESHNESS_TYPE,
|
||||
type MetricFreshnessEntry,
|
||||
} from "./sync-verdict";
|
||||
|
||||
export type HealthKitSyncTrigger =
|
||||
"foreground" | "background" | "push" | "manual";
|
||||
|
||||
const TRIGGERS: ReadonlySet<string> = new Set<HealthKitSyncTrigger>([
|
||||
/** The `syncTrigger` values the Apple Health ingest routes accept. */
|
||||
export const HEALTHKIT_SYNC_TRIGGERS = [
|
||||
"foreground",
|
||||
"background",
|
||||
"push",
|
||||
"manual",
|
||||
]);
|
||||
] as const;
|
||||
|
||||
export type HealthKitSyncTrigger = (typeof HEALTHKIT_SYNC_TRIGGERS)[number];
|
||||
|
||||
const TRIGGERS: ReadonlySet<string> = new Set<string>(HEALTHKIT_SYNC_TRIGGERS);
|
||||
|
||||
/**
|
||||
* The ledger key for Apple Health medication doses, which are not a
|
||||
* `MeasurementType`. Also the `type` of their `metricFreshness` entry.
|
||||
*/
|
||||
export const MEDICATION_DOSE_ARRIVAL_TYPE = MEDICATION_DOSE_FRESHNESS_TYPE;
|
||||
|
||||
/** A ledger key: a measurement type, or the dose key above. */
|
||||
export type HealthKitArrivalType =
|
||||
MeasurementType | typeof MEDICATION_DOSE_ARRIVAL_TYPE;
|
||||
|
||||
/** What one batch did for one type. */
|
||||
export interface HealthKitTypeArrival {
|
||||
@@ -44,7 +60,9 @@ export interface HealthKitTypeArrival {
|
||||
* types). `last_received_at` and `last_trigger` describe the latest batch
|
||||
* that carried the type, so they are overwritten; `last_new_sample_at` only
|
||||
* moves when this batch brought something new, so a duplicates-only re-send
|
||||
* keeps the earlier date standing.
|
||||
* keeps the earlier date standing. `last_background_at` moves only on a
|
||||
* `background` batch, so opening the app afterwards does not hide the
|
||||
* earlier background arrival.
|
||||
*
|
||||
* Best effort: the ledger is a diagnostic beside rows that are already
|
||||
* written, so a failure here leaves a `warn` line and never fails the ingest.
|
||||
@@ -53,7 +71,7 @@ export async function recordHealthKitTypeArrivals(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
userId: string;
|
||||
counts: ReadonlyMap<MeasurementType, HealthKitTypeArrival>;
|
||||
counts: ReadonlyMap<HealthKitArrivalType, HealthKitTypeArrival>;
|
||||
trigger: HealthKitSyncTrigger | null;
|
||||
at?: Date;
|
||||
},
|
||||
@@ -70,13 +88,16 @@ export async function recordHealthKitTypeArrivals(
|
||||
// serialised in the process's local zone and these are zone-less UTC
|
||||
// columns (same reasoning as the access-token sunset in refresh-token.ts).
|
||||
const at = (input.at ?? new Date()).toISOString();
|
||||
const background = input.trigger === "background";
|
||||
try {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "healthkit_type_syncs"
|
||||
("user_id", "type", "last_received_at", "last_trigger", "last_new_sample_at")
|
||||
SELECT ${input.userId}, u.t::"measurement_type",
|
||||
("user_id", "type", "last_received_at", "last_trigger",
|
||||
"last_new_sample_at", "last_background_at")
|
||||
SELECT ${input.userId}, u.t,
|
||||
${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger},
|
||||
CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END
|
||||
CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END,
|
||||
CASE WHEN ${background}::boolean THEN ${at}::timestamptz AT TIME ZONE 'UTC' END
|
||||
FROM unnest(${types}::text[], ${brought}::boolean[]) AS u(t, n)
|
||||
ON CONFLICT ("user_id", "type") DO UPDATE SET
|
||||
"last_received_at" = EXCLUDED."last_received_at",
|
||||
@@ -84,6 +105,10 @@ export async function recordHealthKitTypeArrivals(
|
||||
"last_new_sample_at" = COALESCE(
|
||||
EXCLUDED."last_new_sample_at",
|
||||
"healthkit_type_syncs"."last_new_sample_at"
|
||||
),
|
||||
"last_background_at" = COALESCE(
|
||||
EXCLUDED."last_background_at",
|
||||
"healthkit_type_syncs"."last_background_at"
|
||||
)
|
||||
`;
|
||||
} catch (error) {
|
||||
@@ -98,10 +123,11 @@ export interface HealthKitTypeArrivalRecord {
|
||||
lastReceivedAt: string;
|
||||
lastTrigger: HealthKitSyncTrigger | null;
|
||||
lastNewSampleAt: string | null;
|
||||
lastBackgroundAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every recorded type for one account, keyed by `MeasurementType`. A stored
|
||||
* Every recorded type for one account, keyed by the ledger key. A stored
|
||||
* trigger outside the known set (none is written, but the column is text)
|
||||
* reads as null rather than reaching the client as an unknown label.
|
||||
*/
|
||||
@@ -115,6 +141,7 @@ export async function getHealthKitTypeArrivals(
|
||||
lastReceivedAt: true,
|
||||
lastTrigger: true,
|
||||
lastNewSampleAt: true,
|
||||
lastBackgroundAt: true,
|
||||
},
|
||||
});
|
||||
const out = new Map<string, HealthKitTypeArrivalRecord>();
|
||||
@@ -126,6 +153,7 @@ export async function getHealthKitTypeArrivals(
|
||||
? (row.lastTrigger as HealthKitSyncTrigger)
|
||||
: null,
|
||||
lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null,
|
||||
lastBackgroundAt: row.lastBackgroundAt?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -165,11 +193,13 @@ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry {
|
||||
lastTrigger?: HealthKitSyncTrigger | null;
|
||||
/** When a live sync last brought a new or updated sample of it. */
|
||||
lastNewSampleAt?: string | null;
|
||||
/** When a `background` sync last carried it; a later foreground one keeps it. */
|
||||
lastBackgroundAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the arrival ledger over the per-type freshness entries. Every
|
||||
* measurement type gets the three fields, null when no live sync has carried
|
||||
* measurement type and the `MEDICATION_DOSE` entry get the four fields, null when no live sync has carried
|
||||
* it yet (a type that so far came only from the export import, or from before
|
||||
* the ledger existed). The `WORKOUTS` entry is left without them: workouts
|
||||
* arrive through their own batch route, which the ledger does not cover, and
|
||||
@@ -189,6 +219,7 @@ export function withHealthKitArrivals(
|
||||
lastReceivedAt: arrival?.lastReceivedAt ?? null,
|
||||
lastTrigger: arrival?.lastTrigger ?? null,
|
||||
lastNewSampleAt: arrival?.lastNewSampleAt ?? null,
|
||||
lastBackgroundAt: arrival?.lastBackgroundAt ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
|
||||
import type { IntegrationKey } from "./status";
|
||||
import {
|
||||
MEDICATION_DOSE_FRESHNESS_TYPE,
|
||||
WORKOUT_FRESHNESS_TYPE,
|
||||
type MetricFreshnessSample,
|
||||
} from "./sync-verdict";
|
||||
@@ -185,6 +186,34 @@ export async function getSourceFreshness(
|
||||
return sortByType(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest Apple Health medication dose, as the `MEDICATION_DOSE` sample of
|
||||
* the Apple Health freshness list (v1.42.4). A dose row carries an
|
||||
* `externalId` only when it came from Apple Health (the intake bulk route
|
||||
* accepts one solely with `source: "APPLE_HEALTH"`, and a slot write that
|
||||
* converges onto an existing row keeps the id), so that column, not
|
||||
* `source`, is what identifies them. A taken dose is dated by when it was
|
||||
* taken, a skipped one by its slot. Null when there is no live Apple Health
|
||||
* dose, so the entry is absent rather than invented.
|
||||
*/
|
||||
export async function getAppleHealthDoseFreshness(
|
||||
userId: string,
|
||||
): Promise<MetricFreshnessSample | null> {
|
||||
const rows = await prisma.$queryRaw<Array<{ last_seen: Date | null }>>`
|
||||
SELECT max(COALESCE(e."taken_at", e."scheduled_for")) AS "last_seen"
|
||||
FROM "medication_intake_events" e
|
||||
WHERE e."user_id" = ${userId}
|
||||
AND e."external_id" IS NOT NULL
|
||||
AND e."deleted_at" IS NULL
|
||||
`;
|
||||
const lastSeen = rows[0]?.last_seen;
|
||||
if (!lastSeen) return null;
|
||||
return {
|
||||
type: MEDICATION_DOSE_FRESHNESS_TYPE,
|
||||
lastSeenAt: new Date(lastSeen).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-`(integration, type)` last-value timestamps for the sync
|
||||
* integrations, keyed by `IntegrationKey`. Two grouped queries (measurements +
|
||||
|
||||
@@ -271,14 +271,25 @@ export const SPARSE_METRIC_TYPES: ReadonlySet<string> = new Set([
|
||||
"AUDIO_EXPOSURE_EVENT",
|
||||
// People train irregularly; a quiet training week is not a broken pipe.
|
||||
"WORKOUTS",
|
||||
// As-needed and paused medications leave weeks without a dose by design.
|
||||
"MEDICATION_DOSE",
|
||||
]);
|
||||
|
||||
/** The pseudo-type carrying a source's newest workout, next to its metrics. */
|
||||
export const WORKOUT_FRESHNESS_TYPE = "WORKOUTS";
|
||||
|
||||
/**
|
||||
* The pseudo-type carrying Apple Health medication doses on the Apple Health
|
||||
* freshness list (v1.42.4), and their key in the per-type arrival ledger.
|
||||
*/
|
||||
export const MEDICATION_DOSE_FRESHNESS_TYPE = "MEDICATION_DOSE";
|
||||
|
||||
/** Newest recorded reading for one `(source, type)` pair, as queried. */
|
||||
export interface MetricFreshnessSample {
|
||||
/** The `MeasurementType` (e.g. "RESPIRATORY_RATE"), or `WORKOUTS`. */
|
||||
/**
|
||||
* The `MeasurementType` (e.g. "RESPIRATORY_RATE"), `WORKOUTS`, or
|
||||
* `MEDICATION_DOSE` on the Apple Health list.
|
||||
*/
|
||||
type: string;
|
||||
/** ISO timestamp of the newest (non-deleted) reading for this metric. */
|
||||
lastSeenAt: string;
|
||||
|
||||
@@ -203,7 +203,7 @@ const metricFreshnessEntry = z
|
||||
type: z
|
||||
.string()
|
||||
.describe(
|
||||
"The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.",
|
||||
"The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than fail.",
|
||||
),
|
||||
lastSeenAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
@@ -245,11 +245,18 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry
|
||||
.describe(
|
||||
"When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Null when none has. Same omission rules as `lastReceivedAt`.",
|
||||
),
|
||||
lastBackgroundAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so opening the app does not hide an earlier background arrival. Null when the type has never arrived in the background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose latest arrival then was a background one starts with that date and every other type starts null. Same omission rules as `lastReceivedAt`.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AppleHealthMetricFreshnessEntry",
|
||||
description:
|
||||
"Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42).",
|
||||
'Per-type Apple Health freshness: the newest sample\'s timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42). Since v1.42.4 the list can also hold a `MEDICATION_DOSE` entry: `lastSeenAt` is the newest live dose imported from Apple Health (its `takenAt`, or its slot for a skipped dose), and the arrival facts come from `POST /api/medications/intake/bulk` entries sent with `source: "APPLE_HEALTH"`. A dose recorded in the app does not move them. Doses are never flagged `stale`.',
|
||||
});
|
||||
|
||||
// Issue #778 — the two backfill-progress figures the server genuinely holds.
|
||||
|
||||
@@ -130,7 +130,7 @@ const batchPayloadSchema = z
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.optional()
|
||||
.describe(
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -198,6 +198,12 @@ const bulkIntakeEntry = z
|
||||
const bulkIntakePayload = z
|
||||
.object({
|
||||
entries: z.array(bulkIntakeEntry).min(1).max(500),
|
||||
syncTrigger: z
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.optional()
|
||||
.describe(
|
||||
'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "BulkMedicationIntakeRequest",
|
||||
|
||||
Reference in new issue
Block a user