fix(apple-health): count readings entered here before calling a type quiet (#1247)

Weight, blood pressure and blood glucose are written back to the phone
while their entry is bidirectional. A reading entered here goes to Apple
Health and never arrives back, so a person who keeps those readings here
saw the type go quiet after two weeks. The Apple Health status read now
adds `lastEnteredHereAt` to each written-back type, the newest reading
from any source other than Apple Health, and `stale` counts it; the card
shows it as "Last entered here". The value is resolved on the server so a
client renders it as sent.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 03:36:31 +02:00
1 parent 2b61762962
commit 0497685db0
16 files changed
+271 -21

No files matched your search

@@ -358,6 +358,86 @@ describe("GET /api/integrations/healthkit — sync health", () => {
expect(byType.PULSE).toBe(false);
});
// #1247 — weight entered here goes to Apple Health and never arrives back,
// so a person who keeps their weight here watched the type go quiet.
describe("a type the app writes back to the phone", () => {
function mockWriteBack(enteredHereAt: Date | null): void {
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
"WEIGHT",
"PULSE",
]);
vi.mocked(prisma.$queryRaw).mockImplementation((async (
strings: TemplateStringsArray,
) => {
const sql = strings.join("?");
if (sql.includes("medication_intake_events")) {
return [{ last_seen: null }];
}
if (sql.includes("<> 'APPLE_HEALTH'")) {
return [{ type: "WEIGHT", last_seen: enteredHereAt }];
}
return [
{ type: "WEIGHT", last_seen: ago(30 * DAY) },
{ type: "PULSE", last_seen: ago(30 * DAY) },
];
}) as never);
}
function byType(body: Body) {
return Object.fromEntries(
body.metricFreshness.map((entry) => [entry.type, entry]),
) as Record<
string,
Body["metricFreshness"][number] & {
lastEnteredHereAt?: string | null;
}
>;
}
it("is not quiet while readings are entered here", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
const enteredHereAt = ago(2 * DAY);
mockWriteBack(enteredHereAt);
const entries = byType(await read());
expect(entries.WEIGHT.stale).toBe(false);
expect(entries.WEIGHT.lastEnteredHereAt).toBe(
enteredHereAt.toISOString(),
);
// A read-only type keeps its own verdict and carries no such field.
expect(entries.PULSE.stale).toBe(true);
expect(entries.PULSE).not.toHaveProperty("lastEnteredHereAt");
});
it("is quiet when nothing arrived and nothing was entered here", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
mockWriteBack(null);
const entries = byType(await read());
expect(entries.WEIGHT.stale).toBe(true);
expect(entries.WEIGHT.lastEnteredHereAt).toBeNull();
});
it("counts only what the entry still writes back", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: {
entries: [
{ id: "bodyMass", kind: "bodyMass", direction: "readOnly" },
],
},
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
mockWriteBack(ago(2 * DAY));
const entries = byType(await read());
expect(entries.WEIGHT.stale).toBe(true);
expect(entries.WEIGHT).not.toHaveProperty("lastEnteredHereAt");
});
});
// #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.
+29 -9
View File
@@ -23,11 +23,13 @@ import { auditLog } from "@/lib/auth/audit";
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
import {
getHealthKitTypeArrivals,
healthKitWriteBackTypes,
newestAppleHealthDataAt,
withHealthKitArrivals,
} from "@/lib/integrations/healthkit-type-sync";
import {
getAppleHealthDoseFreshness,
getEnteredHereAt,
getSourceFreshness,
} from "@/lib/integrations/metric-freshness";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
@@ -183,14 +185,27 @@ export const GET = apiHandler(async () => {
// 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),
]);
// #1247 — the types the app writes back to the phone: what is entered here
// goes to Apple Health and never arrives back, so each of them also carries
// the newest reading entered here, and is quiet only when both are. Fail-
// soft to undefined, which leaves the list as it was before.
const [
measurementSamples,
doseSample,
arrivals,
syncProgress,
health,
enteredHere,
] = 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),
getEnteredHereAt(user.id, healthKitWriteBackTypes(entries)).catch(
() => undefined,
),
]);
const samples = doseSample
? [...measurementSamples, doseSample].sort((a, b) =>
a.type.localeCompare(b.type),
@@ -216,7 +231,12 @@ export const GET = apiHandler(async () => {
lastBackgroundSyncAt,
syncHealth,
metricFreshness: withHealthKitArrivals(
classifyMetricFreshness(samples, syncHealth.verdict),
classifyMetricFreshness(
samples,
syncHealth.verdict,
Date.now(),
enteredHere,
),
arrivals,
),
syncProgress,