diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index af613a5d8..e83e22edf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -76528,6 +76528,17 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + lastEnteredHereAt: + description: "v1.43.7 — present only on a type the app writes back to Apple Health (weight, blood pressure, blood + glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the + type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the + phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both + this and `lastSeenAt` are. Show it as the server sends it; do not recompute `stale`." + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" required: - type - lastSeenAt diff --git a/messages/de.json b/messages/de.json index db74e5a99..d54b26bea 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7275,7 +7275,8 @@ "lastNewValue": "Letzter neuer Wert {relative}", "lastBackground": "Zuletzt im Hintergrund {relative}", "never": "Noch nicht empfangen", - "lastBackfill": "Verlauf nachgeladen {relative}" + "lastBackfill": "Verlauf nachgeladen {relative}", + "lastEnteredHere": "Zuletzt hier eingetragen {relative}" } }, "garminInfo": { diff --git a/messages/en.json b/messages/en.json index bc012e734..0809c12dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7275,7 +7275,8 @@ "lastNewValue": "Last new value {relative}", "lastBackground": "Last in background {relative}", "never": "Not received yet", - "lastBackfill": "History caught up {relative}" + "lastBackfill": "History caught up {relative}", + "lastEnteredHere": "Last entered here {relative}" } }, "garminInfo": { diff --git a/messages/es.json b/messages/es.json index f3270594d..0c939ee0b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7275,7 +7275,8 @@ "lastNewValue": "Último valor nuevo {relative}", "lastBackground": "Última vez en segundo plano {relative}", "never": "Aún no recibido", - "lastBackfill": "Historial recuperado {relative}" + "lastBackfill": "Historial recuperado {relative}", + "lastEnteredHere": "Último registro aquí {relative}" } }, "garminInfo": { diff --git a/messages/fr.json b/messages/fr.json index cb5eab05e..283030114 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7275,7 +7275,8 @@ "lastNewValue": "Dernière nouvelle valeur {relative}", "lastBackground": "Dernière fois en arrière-plan {relative}", "never": "Pas encore reçu", - "lastBackfill": "Historique rattrapé {relative}" + "lastBackfill": "Historique rattrapé {relative}", + "lastEnteredHere": "Dernière saisie ici {relative}" } }, "garminInfo": { diff --git a/messages/it.json b/messages/it.json index ffb452c8c..739a0184e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7275,7 +7275,8 @@ "lastNewValue": "Ultimo nuovo valore {relative}", "lastBackground": "Ultima volta in background {relative}", "never": "Non ancora ricevuto", - "lastBackfill": "Storico recuperato {relative}" + "lastBackfill": "Storico recuperato {relative}", + "lastEnteredHere": "Ultimo inserimento qui {relative}" } }, "garminInfo": { diff --git a/messages/ko.json b/messages/ko.json index fabcc9460..da63eea73 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7275,7 +7275,8 @@ "lastNewValue": "마지막 새 값 {relative}", "lastBackground": "마지막 백그라운드 수신 {relative}", "never": "아직 수신되지 않음", - "lastBackfill": "기록 보충 {relative}" + "lastBackfill": "기록 보충 {relative}", + "lastEnteredHere": "여기에서 마지막 입력 {relative}" } }, "garminInfo": { diff --git a/messages/pl.json b/messages/pl.json index 512d0de85..6a8ffff52 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7275,7 +7275,8 @@ "lastNewValue": "Ostatnia nowa wartość {relative}", "lastBackground": "Ostatnio w tle {relative}", "never": "Jeszcze nie odebrano", - "lastBackfill": "Historia uzupełniona {relative}" + "lastBackfill": "Historia uzupełniona {relative}", + "lastEnteredHere": "Ostatni wpis tutaj {relative}" } }, "garminInfo": { diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 4dfb5976e..12afdaa27 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -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. diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index bde6b3da4..b737e8784 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -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, diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index dca0e5e1c..5651aa100 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -531,6 +531,33 @@ describe(" — per-type arrivals (#1173)", () => { expect(out).toContain("Not received yet"); }); + // #1247 — weight entered here goes to the phone and never arrives back. + it("names when a written-back type was last entered here", () => { + const received = html({ + ...base, + type: "WEIGHT", + lastReceivedAt: hoursAgo(400), + lastTrigger: "background", + lastNewSampleAt: hoursAgo(400), + lastEnteredHereAt: hoursAgo(48), + }); + expect(received).toContain("Last entered here 2 d ago"); + const never = html({ + ...base, + type: "WEIGHT", + lastReceivedAt: null, + lastEnteredHereAt: hoursAgo(48), + }); + expect(never).toContain("Not received yet"); + expect(never).toContain("Last entered here 2 d ago"); + const noLedger = html({ + ...base, + type: "WEIGHT", + lastEnteredHereAt: hoursAgo(48), + }); + expect(noLedger).toContain('data-state="entered-here"'); + }); + it("renders nothing when the server sent no arrival facts", () => { expect(html(base)).toBe(""); }); diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index c2f909c11..cbcb16491 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -136,7 +136,9 @@ function DeliveryDiagnostic({ * 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". + * ledger read), so absence is never painted as "not received". A type the app + * writes back to the phone also names when a reading was last entered here + * (#1247), since those never arrive back. */ export function ArrivalDetail({ entry, @@ -147,10 +149,21 @@ export function ArrivalDetail({ t: (key: string, params?: Record) => string; now?: Date; }) { - if (entry.lastReceivedAt === undefined) return null; const reference = (now ?? new Date()).getTime(); const relative = (iso: string) => formatRelative(Math.max(0, reference - new Date(iso).getTime()), t); + const enteredHere = entry.lastEnteredHereAt + ? t("settings.appleHealth.freshness.lastEnteredHere", { + relative: relative(entry.lastEnteredHereAt), + }) + : null; + if (entry.lastReceivedAt === undefined) { + return enteredHere ? ( + + {enteredHere} + + ) : null; + } // v1.43 (ios #77) — a history catch-up is named apart from the live // arrivals, and never counts as one. const backfill = entry.lastBackfillAt @@ -161,7 +174,7 @@ export function ArrivalDetail({ if (entry.lastReceivedAt === null) { return ( - {[t("settings.appleHealth.freshness.never"), backfill] + {[t("settings.appleHealth.freshness.never"), backfill, enteredHere] .filter((part): part is string => part !== null) .join(", ")} @@ -199,6 +212,7 @@ export function ArrivalDetail({ ); } if (backfill) parts.push(backfill); + if (enteredHere) parts.push(enteredHere); return ( +> = { + bodyMass: ["WEIGHT"], + bloodPressure: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + bloodGlucose: ["BLOOD_GLUCOSE"], +}; + +/** The types the person's resolved entries currently write back. */ +export function healthKitWriteBackTypes( + entries: ReadonlyArray<{ kind: string; direction: string; enabled: boolean }>, +): MeasurementType[] { + const types = new Set(); + for (const entry of entries) { + if (!entry.enabled) continue; + if (entry.direction !== "bidirectional" && entry.direction !== "writeOnly") + continue; + for (const type of HEALTHKIT_WRITE_BACK_TYPES[entry.kind] ?? []) { + types.add(type); + } + } + return [...types].sort(); +} + /** A ledger key: a measurement type, or the dose key above. */ export type HealthKitArrivalType = MeasurementType | typeof MEDICATION_DOSE_ARRIVAL_TYPE; diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts index bcda04877..6cd5d56b1 100644 --- a/src/lib/integrations/metric-freshness.ts +++ b/src/lib/integrations/metric-freshness.ts @@ -214,6 +214,44 @@ export async function getAppleHealthDoseFreshness( }; } +/** + * The newest reading entered here, not arrived from Apple Health, for each of + * `types` (#1247): the readings the app writes back to the phone. Every + * requested type has a key, null when there is none, so a write-back type is + * recognisable on the wire even before its first entry. + */ +export async function getEnteredHereAt( + userId: string, + types: readonly MeasurementType[], +): Promise> { + const out = new Map(types.map((t) => [t, null])); + if (types.length === 0) return out; + // One `LIMIT 1` per type down the (user, type, measured_at) index; the + // enum casts are literal SQL, everything else is bound. + const rows = await prisma.$queryRaw< + Array<{ type: string; last_seen: Date | null }> + >` + SELECT wanted."type"::text AS "type", + ( + SELECT m."measured_at" + FROM "measurements" m + WHERE m."user_id" = ${userId} + AND m."type" = wanted."type" + AND m."deleted_at" IS NULL + AND m."source" <> 'APPLE_HEALTH'::"measurement_source" + ORDER BY m."measured_at" DESC + LIMIT 1 + ) AS "last_seen" + FROM unnest(${[...types]}::"measurement_type"[]) AS wanted("type") + `; + for (const row of rows) { + if (row.last_seen && out.has(row.type)) { + out.set(row.type, new Date(row.last_seen).toISOString()); + } + } + return out; +} + /** * Compute per-`(integration, type)` last-value timestamps for the sync * integrations, keyed by `IntegrationKey`. Two grouped queries (measurements + diff --git a/src/lib/integrations/sync-verdict.ts b/src/lib/integrations/sync-verdict.ts index 52ca961ee..5b911fc40 100644 --- a/src/lib/integrations/sync-verdict.ts +++ b/src/lib/integrations/sync-verdict.ts @@ -314,6 +314,12 @@ export interface MetricFreshnessEntry extends MetricFreshnessSample { * `classifyMetricFreshness`. */ stale: boolean; + /** + * Apple Health types the app writes back to the phone only (#1247): the + * newest reading entered here rather than arriving from Apple Health, or + * null when there is none. Absent on every other entry. + */ + lastEnteredHereAt?: string | null; } /** @@ -330,15 +336,27 @@ export function classifyMetricFreshness( samples: readonly MetricFreshnessSample[], verdict: SyncVerdict, now: Date | number = Date.now(), + enteredHere?: ReadonlyMap, ): MetricFreshnessEntry[] { const nowMs = now instanceof Date ? now.getTime() : now; return samples.map((sample) => { + // A type the app writes back to the phone (#1247): what is entered here + // goes to Apple Health and never arrives back, so a person who keeps + // their readings here would otherwise watch the type go quiet. The newer + // of the two is what says whether the type is still in use. + const written = enteredHere?.has(sample.type) + ? { lastEnteredHereAt: enteredHere.get(sample.type) ?? null } + : {}; if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) { - return { ...sample, stale: false }; + return { ...sample, ...written, stale: false }; } - const seen = toMillis(sample.lastSeenAt); + const seen = newest( + toMillis(sample.lastSeenAt), + toMillis(written.lastEnteredHereAt), + ); return { ...sample, + ...written, stale: seen != null && nowMs - seen > METRIC_QUIET_AFTER_MS, }; }); diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 5d8c057f3..9fc6ec356 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -289,6 +289,12 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry .describe( 'v1.43 — when a batch with `syncTrigger: "backfill"` last carried this type. A backfill moves only this and `syncProgress`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt` or the account-level sync stamps, so catching up history does not read as a run of healthy daily syncs. Absent on older servers.', ), + lastEnteredHereAt: isoDateTime() + .nullable() + .optional() + .describe( + "v1.43.7 — present only on a type the app writes back to Apple Health (weight, blood pressure, blood glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both this and `lastSeenAt` are. Show it as the server sends it; do not recompute `stale`.", + ), }) .meta({ id: "AppleHealthMetricFreshnessEntry",