mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
2b61762962
commit
0497685db0
16 files changed
+271
-21
No files matched your search
@@ -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
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
+2
-1
@@ -7275,7 +7275,8 @@
|
||||
"lastNewValue": "마지막 새 값 {relative}",
|
||||
"lastBackground": "마지막 백그라운드 수신 {relative}",
|
||||
"never": "아직 수신되지 않음",
|
||||
"lastBackfill": "기록 보충 {relative}"
|
||||
"lastBackfill": "기록 보충 {relative}",
|
||||
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -531,6 +531,33 @@ describe("<AppleHealthCard> — 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("");
|
||||
});
|
||||
|
||||
@@ -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, string | number>) => 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 ? (
|
||||
<span data-testid="apple-health-arrival" data-state="entered-here">
|
||||
{enteredHere}
|
||||
</span>
|
||||
) : 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 (
|
||||
<span data-testid="apple-health-arrival" data-state="never">
|
||||
{[t("settings.appleHealth.freshness.never"), backfill]
|
||||
{[t("settings.appleHealth.freshness.never"), backfill, enteredHere]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(", ")}
|
||||
</span>
|
||||
@@ -199,6 +212,7 @@ export function ArrivalDetail({
|
||||
);
|
||||
}
|
||||
if (backfill) parts.push(backfill);
|
||||
if (enteredHere) parts.push(enteredHere);
|
||||
return (
|
||||
<span
|
||||
data-testid="apple-health-arrival"
|
||||
|
||||
@@ -77,6 +77,35 @@ export function isBackfillLedgerKey(type: string): boolean {
|
||||
*/
|
||||
export const MEDICATION_DOSE_ARRIVAL_TYPE = MEDICATION_DOSE_FRESHNESS_TYPE;
|
||||
|
||||
/**
|
||||
* The measurement types behind each Apple Health entry kind the app writes
|
||||
* back to the phone (#1247). A reading entered here goes to Apple Health and
|
||||
* is never sent back, so these types are counted from both directions.
|
||||
*/
|
||||
export const HEALTHKIT_WRITE_BACK_TYPES: Readonly<
|
||||
Record<string, readonly MeasurementType[]>
|
||||
> = {
|
||||
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<MeasurementType>();
|
||||
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;
|
||||
|
||||
@@ -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<Map<string, string | null>> {
|
||||
const out = new Map<string, string | null>(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 +
|
||||
|
||||
@@ -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<string, string | null>,
|
||||
): 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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user