mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'v1423/review' into release/v1.42.3
This commit is contained in:
commit
784ea4ca9d
10 files changed
+593
-70
No files matched your search
@@ -192,6 +192,9 @@ export const PUT = apiHandler(
|
||||
const data: Record<string, unknown> = {};
|
||||
if (d.panel !== undefined) data.panel = d.panel;
|
||||
if (d.analyte !== undefined) data.analyte = d.analyte;
|
||||
// Set when a changed value retires a conversion whose printed range text is
|
||||
// in the report's unit (see below).
|
||||
let dropPrintedText = false;
|
||||
if (d.value !== undefined) {
|
||||
data.value = d.value;
|
||||
// A number the person typed is no longer the conversion of what the
|
||||
@@ -205,6 +208,18 @@ export const PUT = apiHandler(
|
||||
) {
|
||||
data.sourceValue = null;
|
||||
data.sourceUnit = null;
|
||||
// The printed range text belonged to the printed value and is written
|
||||
// in the report's unit. With `sourceUnit` gone nothing would say so,
|
||||
// and every reader would present it as the marker unit's window, so
|
||||
// it goes with the record. The bounds stay: they were converted with
|
||||
// the value and are already in the marker's unit.
|
||||
if (
|
||||
existing.sourceUnit !== null &&
|
||||
existing.sourceReferenceText !== null
|
||||
) {
|
||||
data.sourceReferenceText = null;
|
||||
dropPrintedText = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a
|
||||
@@ -220,7 +235,15 @@ export const PUT = apiHandler(
|
||||
if (d.sourceReferenceHigh !== undefined) {
|
||||
data.sourceReferenceHigh = d.sourceReferenceHigh;
|
||||
}
|
||||
if (d.sourceReferenceText !== undefined) {
|
||||
// A whole-form echo of the old printed text does not bring it back once the
|
||||
// value change retired it; a text that differs is a new statement and wins.
|
||||
if (
|
||||
d.sourceReferenceText !== undefined &&
|
||||
!(
|
||||
dropPrintedText &&
|
||||
d.sourceReferenceText === existing.sourceReferenceText
|
||||
)
|
||||
) {
|
||||
data.sourceReferenceText = d.sourceReferenceText;
|
||||
}
|
||||
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
|
||||
|
||||
@@ -12,6 +12,18 @@ describe("valueEditSeed", () => {
|
||||
expect(valueEditSeed(110)).toBe("110");
|
||||
expect(valueEditSeed(4.567)).toBe("4.57");
|
||||
});
|
||||
|
||||
it("never opens a small nonzero value as zero", () => {
|
||||
expect(valueEditSeed(0.004)).toBe("0.004");
|
||||
expect(valueEditSeed(0.0123456)).toBe("0.0123");
|
||||
expect(valueEditSeed(0.87)).toBe("0.87");
|
||||
expect(valueEditSeed(0.123456)).toBe("0.123");
|
||||
expect(valueEditSeed(-0.004)).toBe("-0.004");
|
||||
expect(valueEditSeed(0.0000004)).toBe("0.0000004");
|
||||
for (const v of [0.004, 0.0004, 0.00004, 1e-9]) {
|
||||
expect(Number(valueEditSeed(v))).not.toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveValueForUpdate", () => {
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import { formatLabValue } from "./format-value";
|
||||
|
||||
/**
|
||||
* The numeric value of a lab reading in the edit sheet.
|
||||
*
|
||||
* A converted reading is stored at full precision (5.6 mmol/L of glucose is
|
||||
* 100.90191999999999 mg/dL), and the sheet must not open with that tail. The
|
||||
* field starts from the value as every lab surface shows it: whole numbers
|
||||
* bare, at most two decimals. Written with a dot, which the sheet's parser
|
||||
* reads, rather than in the reader's locale, which an input cannot round-trip.
|
||||
* field starts from the value at the precision every lab surface shows it:
|
||||
* whole numbers bare, two decimals. Two decimals carry too little of a small
|
||||
* value, though (a TSH of 0.004 would open as "0"), so below 1 the field keeps
|
||||
* three significant figures instead. Written with a dot, which the sheet's
|
||||
* parser reads, rather than in the reader's locale, which an input cannot
|
||||
* round-trip, and never in exponent notation.
|
||||
*/
|
||||
export function valueEditSeed(value: number): string {
|
||||
return formatLabValue(value, String);
|
||||
if (Number.isInteger(value)) return String(value);
|
||||
const magnitude = Math.floor(Math.log10(Math.abs(value)));
|
||||
const decimals = Math.min(
|
||||
MAX_SEED_DECIMALS,
|
||||
Math.max(DISPLAY_DECIMALS, SEED_SIGNIFICANT_FIGURES - 1 - magnitude),
|
||||
);
|
||||
return value
|
||||
.toFixed(decimals)
|
||||
.replace(/(\.\d*?)0+$/, "$1")
|
||||
.replace(/\.$/, "");
|
||||
}
|
||||
|
||||
const DISPLAY_DECIMALS = 2;
|
||||
const SEED_SIGNIFICANT_FIGURES = 3;
|
||||
/** `toFixed` accepts up to 100; no lab value needs more than this. */
|
||||
const MAX_SEED_DECIMALS = 20;
|
||||
|
||||
/**
|
||||
* What the edit PUT sends for the value. A field the person did not touch is
|
||||
* omitted (`undefined`), so the stored full-precision number stays as it is and
|
||||
|
||||
@@ -950,6 +950,50 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () =
|
||||
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
|
||||
});
|
||||
|
||||
it("keeps a 401 on a core collection a failure even when siblings succeed", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
fetchReadinessMock.mockResolvedValue([
|
||||
{ id: "x", day: "2026-06-10", score: 70 },
|
||||
]);
|
||||
fetchSleepMock.mockRejectedValue(err401());
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.failed).toBe(true);
|
||||
expect(scopeDeniedMock).not.toHaveBeenCalledWith(
|
||||
"u1",
|
||||
expect.arrayContaining(["sleep"]),
|
||||
);
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ integration: "oura" }),
|
||||
);
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let a stored core name skip the refresh", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: ["sleep"] });
|
||||
fetchReadinessMock.mockResolvedValue([
|
||||
{ id: "x", day: "2026-06-10", score: 70 },
|
||||
]);
|
||||
fetchSleepMock.mockRejectedValueOnce(err401()).mockResolvedValue([]);
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.failed).toBe(false);
|
||||
});
|
||||
|
||||
it("still treats a 401 on every collection as a dead token", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
for (const m of [
|
||||
@@ -973,9 +1017,40 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () =
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []);
|
||||
// Nothing proved the token, so nothing was learned about the grant.
|
||||
expect(scopeDeniedMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ integration: "oura", kind: "reauth_required" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the scope note when the refresh is abandoned for want of credentials", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
|
||||
// A new 401 the note does not explain sends the run to the refresh.
|
||||
fetchSleepMock.mockRejectedValue(err401());
|
||||
denyScopedCollections();
|
||||
getCredsMock.mockResolvedValue(null);
|
||||
|
||||
await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(scopeDeniedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the scope note when the refresh loses its token to a peer", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
|
||||
fetchSleepMock.mockRejectedValue(err401());
|
||||
denyScopedCollections();
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
storeTokensMock.mockResolvedValue(null);
|
||||
|
||||
await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(scopeDeniedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+45
-24
@@ -91,6 +91,7 @@ import {
|
||||
} from "./credentials";
|
||||
import { OuraApiError, classifyOuraError } from "./response-classifier";
|
||||
import { syncUserOuraCyclePhases } from "./cycle-sync";
|
||||
import { OURA_SCOPE_GATED_COLLECTIONS } from "./scope";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
@@ -245,11 +246,13 @@ export interface OuraFetchResult {
|
||||
*/
|
||||
sleepSweeps: SleepSegmentSweep[];
|
||||
/**
|
||||
* Collections refused with 401/403 while a sibling succeeded with the same
|
||||
* Collections refused with 401 while a sibling succeeded with the same
|
||||
* token: the token is fine, the grant does not cover them. Not failures; the
|
||||
* card asks for one reconnect instead.
|
||||
* card asks for one reconnect instead. `null` when the run never proved the
|
||||
* token (no collection succeeded, or the refresh was abandoned): it learned
|
||||
* nothing about the grant, so the note from the last run that did stands.
|
||||
*/
|
||||
scopeDenied: string[];
|
||||
scopeDenied: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,24 +264,35 @@ function isAuthRefusal(err: unknown): boolean {
|
||||
return err instanceof OuraApiError && err.httpStatus === 401;
|
||||
}
|
||||
|
||||
const SCOPE_GATED: ReadonlySet<string> = new Set(OURA_SCOPE_GATED_COLLECTIONS);
|
||||
|
||||
/**
|
||||
* Split the auth refusals out of a run's failures once a sibling collection has
|
||||
* proven the token. Oura's authorization server enforces scopes per collection,
|
||||
* so a grant issued without `spo2` / `heart_health` answers 401 on exactly the
|
||||
* collections behind them and 200 everywhere else. Counting those toward the
|
||||
* reconnect ladder painted a working connection as broken on every sync, and
|
||||
* refreshing the token over them changed nothing.
|
||||
* A 401 that can be the grant's scope rather than the token: only the
|
||||
* collections behind `spo2` / `heart_health` sit outside the `daily` scope
|
||||
* every grant carries. A 401 on sleep, readiness or activity is a real auth
|
||||
* failure whatever its siblings did.
|
||||
*/
|
||||
function isScopeRefusal(f: OuraCollectionFailure): boolean {
|
||||
return SCOPE_GATED.has(f.name) && isAuthRefusal(f.err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the scope refusals out of a run's failures once a sibling collection
|
||||
* has proven the token. Oura's authorization server enforces scopes per
|
||||
* collection, so a grant issued without `spo2` / `heart_health` answers 401 on
|
||||
* exactly the collections behind them and 200 everywhere else. Counting those
|
||||
* toward the reconnect ladder painted a working connection as broken on every
|
||||
* sync, and refreshing the token over them changed nothing. Any other failure,
|
||||
* a 401 on a core collection included, stays a failure.
|
||||
*/
|
||||
function splitScopeDenied(
|
||||
failures: OuraCollectionFailure[],
|
||||
tokenProven: boolean,
|
||||
): { failures: OuraCollectionFailure[]; scopeDenied: string[] } {
|
||||
if (!tokenProven) return { failures, scopeDenied: [] };
|
||||
): { failures: OuraCollectionFailure[]; scopeDenied: string[] | null } {
|
||||
if (!tokenProven) return { failures, scopeDenied: null };
|
||||
return {
|
||||
failures: failures.filter((f) => !isAuthRefusal(f.err)),
|
||||
scopeDenied: failures
|
||||
.filter((f) => isAuthRefusal(f.err))
|
||||
.map((f) => f.name),
|
||||
failures: failures.filter((f) => !isScopeRefusal(f)),
|
||||
scopeDenied: failures.filter(isScopeRefusal).map((f) => f.name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -455,7 +469,9 @@ async function fetchAll(
|
||||
// to collections that succeeded, is the grant's scope again: a refresh cannot
|
||||
// widen it, so none is spent. Any other 401 still takes the one refresh, and
|
||||
// whatever 401s after it beside a success is recorded as scope.
|
||||
const knownDenied = new Set(knownScopeDenied);
|
||||
const knownDenied = new Set(
|
||||
knownScopeDenied.filter((name) => SCOPE_GATED.has(name)),
|
||||
);
|
||||
const onlyKnownScope401 = first.failures
|
||||
.filter((f) => isAuthRefusal(f.err))
|
||||
.every((f) => knownDenied.has(f.name));
|
||||
@@ -476,7 +492,7 @@ async function fetchAll(
|
||||
readings: first.readings,
|
||||
failures: first.failures,
|
||||
sleepSweeps,
|
||||
scopeDenied: [],
|
||||
scopeDenied: null,
|
||||
};
|
||||
|
||||
const rotated = await refreshAccessToken(refreshToken, creds);
|
||||
@@ -494,7 +510,7 @@ async function fetchAll(
|
||||
readings: first.readings,
|
||||
failures: first.failures,
|
||||
sleepSweeps,
|
||||
scopeDenied: [],
|
||||
scopeDenied: null,
|
||||
};
|
||||
|
||||
const retry = await attempt(usableToken, first.failed);
|
||||
@@ -551,15 +567,20 @@ export async function syncUserOura(
|
||||
// Remember which collections the grant does not cover, so the settings card
|
||||
// can ask for one reconnect. Best-effort: the note is a convenience, the
|
||||
// sync outcome does not depend on it.
|
||||
if (result.scopeDenied.length > 0) {
|
||||
// A run that never proved the token says nothing about the grant and leaves
|
||||
// the note as it was.
|
||||
const scopeDenied = result.scopeDenied;
|
||||
if (scopeDenied && scopeDenied.length > 0) {
|
||||
annotate({
|
||||
meta: { oura_scope_denied: result.scopeDenied },
|
||||
meta: { oura_scope_denied: scopeDenied },
|
||||
});
|
||||
}
|
||||
try {
|
||||
await syncOuraScopeDenied(userId, result.scopeDenied);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
|
||||
if (scopeDenied) {
|
||||
try {
|
||||
await syncOuraScopeDenied(userId, scopeDenied);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear whatever an earlier scoring left under the re-fetched sleep records
|
||||
|
||||
@@ -105,10 +105,12 @@ import {
|
||||
import {
|
||||
releasePlanGatedWithingsParks,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_MEASTYPES,
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
} from "../api-plan";
|
||||
import { withingsPlanUnavailableFeatures } from "../plan-features";
|
||||
import { WITHINGS_MEASTYPES } from "../client";
|
||||
|
||||
const LAST_GOOD_SYNC = new Date("2026-10-12T21:12:00Z");
|
||||
|
||||
@@ -315,6 +317,102 @@ describe("syncUserMeasurements inside the Withings API plan", () => {
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never remembers a type the budget could not resolve, and converges over runs", async () => {
|
||||
// Two undocumented refusals on top of the documented set. Over the 19
|
||||
// types the first run runs out of budget before it reaches 12, 54, 71,
|
||||
// 76, 77 and 88, all free.
|
||||
refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6, 35]);
|
||||
let stored: unknown = null;
|
||||
let lastSyncedAt = LAST_GOOD_SYNC;
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockImplementation(
|
||||
(async () => connection(stored, lastSyncedAt)) as never,
|
||||
);
|
||||
vi.mocked(prisma.withingsConnection.update).mockImplementation(
|
||||
(async (args: { data: { lastSyncedAt: Date } }) => {
|
||||
lastSyncedAt = args.data.lastSyncedAt;
|
||||
return {};
|
||||
}) as never,
|
||||
);
|
||||
const runs: Array<{ unavailable: number[]; startdates: string[] }> = [];
|
||||
const read = new Set<number>();
|
||||
|
||||
for (let run = 0; run < 4; run++) {
|
||||
vi.mocked(prisma.$executeRaw).mockClear();
|
||||
vi.mocked(prisma.measurement.create).mockClear();
|
||||
requestedStartdates.length = 0;
|
||||
await syncUserMeasurements("user-1");
|
||||
const [persisted] = persistedLegs();
|
||||
if (persisted) {
|
||||
const value = persisted.value as {
|
||||
unavailable: number[];
|
||||
checkedAt: string;
|
||||
};
|
||||
stored = { version: 1, measures: value };
|
||||
}
|
||||
for (const types of requestedTypes.splice(0)) {
|
||||
if (!types.some((t) => refused.has(t)))
|
||||
types.forEach((t) => read.add(t));
|
||||
}
|
||||
const current = stored as {
|
||||
measures?: { unavailable: number[] };
|
||||
} | null;
|
||||
runs.push({
|
||||
unavailable: current?.measures?.unavailable ?? [],
|
||||
startdates: [...requestedStartdates],
|
||||
});
|
||||
}
|
||||
|
||||
const truly = [...refused].sort((a, b) => a - b);
|
||||
// No run ever stored a type Withings did not refuse.
|
||||
for (const r of runs) {
|
||||
expect(r.unavailable.every((t) => refused.has(t))).toBe(true);
|
||||
}
|
||||
expect(runs.at(-1)!.unavailable).toEqual(truly);
|
||||
// Every type the plan allows was read in an accepted request, the free
|
||||
// ones the first run never reached included.
|
||||
expect(read).toEqual(
|
||||
new Set(WITHINGS_MEASTYPES.filter((t) => !refused.has(t))),
|
||||
);
|
||||
expect([...read]).toEqual(expect.arrayContaining([12, 54, 71, 76, 77, 88]));
|
||||
// The first run read nothing for the unresolved types, so the second
|
||||
// still reads from the last good sync.
|
||||
const fromLastGood = String(
|
||||
Math.floor(
|
||||
(LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000,
|
||||
),
|
||||
);
|
||||
expect(runs[1]!.startdates.every((s) => s === fromLastGood)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the remembered date when a run that is not the re-probe finds one more refusal", async () => {
|
||||
const remembered = new Date(Date.now() - 3 * 86_400_000).toISOString();
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [...WITHINGS_ADVANCED_MEASTYPES],
|
||||
checkedAt: remembered,
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
// The plan starts refusing fat ratio too.
|
||||
refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6]);
|
||||
|
||||
await syncUserMeasurements("user-1");
|
||||
|
||||
expect(persistedLegs()).toEqual([
|
||||
{
|
||||
leg: "measures",
|
||||
value: {
|
||||
unavailable: [6, ...WITHINGS_ADVANCED_MEASTYPES].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
checkedAt: remembered,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the whole gap since the last good sync", async () => {
|
||||
await syncUserMeasurements("user-1");
|
||||
|
||||
@@ -326,19 +424,128 @@ describe("syncUserMeasurements inside the Withings API plan", () => {
|
||||
expect(requestedStartdates.every((s) => s === expected)).toBe(true);
|
||||
});
|
||||
|
||||
it("holds the watermark and records no failure when the plan refuses everything", async () => {
|
||||
it("treats a run where every request was refused as transient: nothing remembered, watermark held", async () => {
|
||||
refused = new Set([
|
||||
1, 5, 6, 8, 9, 10, 11, 12, 35, 54, 71, 76, 77, 88, 91, 123, 155, 170, 73,
|
||||
]);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(result).toEqual({ imported: 0, failed: false });
|
||||
expect(result).toEqual({ imported: 0, failed: true });
|
||||
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
|
||||
// A 277 stays off the ledger, and a run that read nothing is no success.
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
expect(requestedTypes.length).toBeLessThanOrEqual(
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
);
|
||||
// Weight and blood pressure are not remembered as refused.
|
||||
expect(persistedLegs()).toEqual([]);
|
||||
|
||||
// The fault clears; the next run asks for everything again and reads the
|
||||
// whole gap since the last good sync.
|
||||
refused = new Set(WITHINGS_ADVANCED_MEASTYPES);
|
||||
requestedTypes.length = 0;
|
||||
requestedStartdates.length = 0;
|
||||
const next = await syncUserMeasurements("user-1");
|
||||
expect(next.failed).toBe(false);
|
||||
expect(requestedTypes[0]).toEqual(expect.arrayContaining([1, 9, 10, 11]));
|
||||
expect(createdTypes()).toEqual(
|
||||
expect.arrayContaining(["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"]),
|
||||
);
|
||||
const fromLastGood = String(
|
||||
Math.floor(
|
||||
(LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000,
|
||||
),
|
||||
);
|
||||
expect(requestedStartdates.every((s) => s === fromLastGood)).toBe(true);
|
||||
});
|
||||
|
||||
it("holds the watermark when plan memory leaves nothing to request", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [...WITHINGS_MEASTYPES],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(0);
|
||||
expect(result).toEqual({ imported: 0, failed: true });
|
||||
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("holds the watermark when a core type was skipped from plan memory", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [1, ...WITHINGS_ADVANCED_MEASTYPES],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(1);
|
||||
expect(requestedTypes[0]).not.toContain(1);
|
||||
expect(result.failed).toBe(false);
|
||||
expect(createdTypes()).toContain("BLOOD_PRESSURE_SYS");
|
||||
// Weight was not read in this window, so the window stays open for it.
|
||||
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still moves the watermark when only advanced types are remembered", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [...WITHINGS_ADVANCED_MEASTYPES],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
await syncUserMeasurements("user-1");
|
||||
|
||||
expect(prisma.withingsConnection.update).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
data: { lastSyncedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldPersistPlanLeg", () => {
|
||||
it("remembers a refusal only from a run where something else was accepted", () => {
|
||||
expect(
|
||||
shouldPersistPlanLeg({ gated: true, reprobed: false, acceptedAny: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPersistPlanLeg({
|
||||
gated: true,
|
||||
reprobed: false,
|
||||
acceptedAny: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPersistPlanLeg({ gated: true, reprobed: true, acceptedAny: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPersistPlanLeg({ gated: false, reprobed: true, acceptedAny: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPersistPlanLeg({
|
||||
gated: false,
|
||||
reprobed: false,
|
||||
acceptedAny: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -423,7 +630,7 @@ describe("requestWithinPlan", () => {
|
||||
expect(second.unavailable.sort()).toEqual(["c", "f"]);
|
||||
});
|
||||
|
||||
it("stops at the request budget and counts what is left as unavailable", async () => {
|
||||
it("stops at the request budget and leaves what is left unverified, not unavailable", async () => {
|
||||
const result = await requestWithinPlan({
|
||||
keys: ["a", "b", "c", "d", "e", "f", "g", "h"],
|
||||
knownGated: [],
|
||||
@@ -433,7 +640,10 @@ describe("requestWithinPlan", () => {
|
||||
},
|
||||
});
|
||||
expect(result.requests).toBe(3);
|
||||
expect(result.unavailable.sort()).toEqual([
|
||||
// Only the requests actually made prove anything: [a..h], [a..d] and [a,b]
|
||||
// were refused, which isolates no single key.
|
||||
expect(result.unavailable).toEqual([]);
|
||||
expect(result.unverified.sort()).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* documents as advanced, then, if it is still refused, splits what is left
|
||||
* in halves until every accepted subset has been read and every refused key
|
||||
* is isolated. Bounded by `WITHINGS_PLAN_MAX_REQUESTS`; anything the budget
|
||||
* could not resolve counts as unavailable until the next probe.
|
||||
* could not resolve is unverified, never remembered as refused, and asked
|
||||
* for again on the next run, which starts from what this one proved.
|
||||
* - `WithingsApiPlanState` remembers per request family what the plan
|
||||
* refused, so later runs skip those keys without paying a refused round
|
||||
* trip, and re-probes them weekly in case the account's plan changes.
|
||||
@@ -57,6 +58,18 @@ export const WITHINGS_ADVANCED_MEASTYPES: readonly number[] = [
|
||||
91, 155, 170, 123, 73,
|
||||
];
|
||||
|
||||
/**
|
||||
* The meastypes Withings lists as free in every plan and that carry the core
|
||||
* of the measure leg: weight (1), blood pressure (9, 10), pulse (11) and the
|
||||
* body-composition readings the table lists as free (fat-free mass 5, fat
|
||||
* mass 8, muscle mass 76, hydration 77, bone mass 88). A run that leaves one
|
||||
* of them out on the strength of plan memory has not read the window for it,
|
||||
* so the measure watermark must not move past it.
|
||||
*/
|
||||
export const WITHINGS_CORE_MEASTYPES: readonly number[] = [
|
||||
1, 5, 8, 9, 10, 11, 76, 77, 88,
|
||||
];
|
||||
|
||||
/**
|
||||
* Sleep-summary fields HealthLog requests that the table marks advanced: the
|
||||
* sleep score, the overnight HRV and the respiration rate. The nightly average
|
||||
@@ -91,8 +104,16 @@ const PLAN_GATED_MESSAGE = /Withings\s+\w+\s+error:\s*277\b/;
|
||||
export interface WithinPlanResult<K, R> {
|
||||
/** Everything the accepted requests returned. */
|
||||
results: R[];
|
||||
/** The requested keys the plan refused (or the budget could not clear). */
|
||||
/**
|
||||
* The requested keys the plan refused: isolated alone, or dropped as
|
||||
* documented advanced after the whole request was refused.
|
||||
*/
|
||||
unavailable: K[];
|
||||
/**
|
||||
* The requested keys the budget ran out on. Neither read nor proven
|
||||
* refused: not to be remembered as unavailable, and to be asked for again.
|
||||
*/
|
||||
unverified: K[];
|
||||
/** True when at least one request was refused with 277. */
|
||||
gated: boolean;
|
||||
/** True when at least one request was accepted. */
|
||||
@@ -108,8 +129,9 @@ export interface WithinPlanResult<K, R> {
|
||||
* are dropped and the rest asked for again; on a further 277 the remainder is
|
||||
* split in halves, depth first and in input order, so the same refusal always
|
||||
* spends the same requests. A single key that is still refused is unavailable.
|
||||
* When the budget runs out, the subsets not yet tried count as unavailable for
|
||||
* now; the weekly re-probe asks for them again.
|
||||
* When the budget runs out, the subsets not yet resolved are unverified: the
|
||||
* caller remembers only what was proven, so the next run skips that and spends
|
||||
* its budget on the rest.
|
||||
*
|
||||
* Any error other than a 277 propagates unchanged: this only decides what the
|
||||
* plan allows, never whether the connection works.
|
||||
@@ -124,6 +146,7 @@ export async function requestWithinPlan<K, R>(opts: {
|
||||
const keys = [...opts.keys];
|
||||
const results: R[] = [];
|
||||
const unavailable: K[] = [];
|
||||
const unverified: K[] = [];
|
||||
let requests = 0;
|
||||
let gated = false;
|
||||
let acceptedAny = false;
|
||||
@@ -143,10 +166,10 @@ export async function requestWithinPlan<K, R>(opts: {
|
||||
};
|
||||
|
||||
if (keys.length === 0) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
return { results, unavailable, unverified, gated, acceptedAny, requests };
|
||||
}
|
||||
if (await attempt(keys)) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
return { results, unavailable, unverified, gated, acceptedAny, requests };
|
||||
}
|
||||
|
||||
const knownGated = new Set(opts.knownGated);
|
||||
@@ -155,14 +178,14 @@ export async function requestWithinPlan<K, R>(opts: {
|
||||
if (dropped.length > 0) {
|
||||
unavailable.push(...dropped);
|
||||
if (remaining.length === 0) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
return { results, unavailable, unverified, gated, acceptedAny, requests };
|
||||
}
|
||||
if (requests >= budget) {
|
||||
unavailable.push(...remaining);
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
unverified.push(...remaining);
|
||||
return { results, unavailable, unverified, gated, acceptedAny, requests };
|
||||
}
|
||||
if (await attempt(remaining)) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
return { results, unavailable, unverified, gated, acceptedAny, requests };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,14 +193,14 @@ export async function requestWithinPlan<K, R>(opts: {
|
||||
// part has been read and each refused key stands alone.
|
||||
if (remaining.length === 1) {
|
||||
unavailable.push(...remaining);
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
return { results, unavailable, unverified, gated, acceptedAny, requests };
|
||||
}
|
||||
const stack: K[][] = halves(remaining).reverse();
|
||||
remaining = [];
|
||||
while (stack.length > 0) {
|
||||
const subset = stack.pop()!;
|
||||
if (requests >= budget) {
|
||||
unavailable.push(...subset);
|
||||
unverified.push(...subset);
|
||||
continue;
|
||||
}
|
||||
if (await attempt(subset)) continue;
|
||||
@@ -188,7 +211,7 @@ export async function requestWithinPlan<K, R>(opts: {
|
||||
stack.push(...halves(subset).reverse());
|
||||
}
|
||||
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
return { results, unavailable, unverified, gated, acceptedAny, requests };
|
||||
}
|
||||
|
||||
function halves<K>(list: K[]): K[][] {
|
||||
@@ -274,6 +297,24 @@ export function planSkipFor<K>(
|
||||
return leg.unavailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `checkedAt` to store with a run's outcome. Only a re-probe asked for the
|
||||
* full set, so only a re-probe (or a leg with nothing remembered yet) starts
|
||||
* the week again. A run that skipped remembered keys and found one more
|
||||
* refusal keeps the old date: moving it would push the re-probe of the keys it
|
||||
* never asked for further out on every such run.
|
||||
*/
|
||||
export function planCheckedAtFor<K>(
|
||||
leg: WithingsPlanLegState<K> | undefined,
|
||||
reprobed: boolean,
|
||||
now: Date,
|
||||
): string {
|
||||
if (reprobed || !leg || leg.unavailable.length === 0) {
|
||||
return now.toISOString();
|
||||
}
|
||||
return leg.checkedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one leg of the plan state. A single statement merges the leg into the
|
||||
* stored object, so the measures, sleep and ECG legs (separate crons, separate
|
||||
@@ -312,15 +353,23 @@ export async function persistWithingsPlanLeg(
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a run's outcome is worth writing down. A run that met a 277
|
||||
* always is; so is a re-probe (the full set was asked for), whatever it found;
|
||||
* a run that skipped remembered keys and was accepted changes nothing.
|
||||
* Decide whether a run's outcome is worth writing down.
|
||||
*
|
||||
* A run that met a 277 is, but only when some other request in the same run
|
||||
* was accepted: a plan never refuses everything, so a run where every request
|
||||
* was refused (a transient fault upstream, a misconfigured developer app)
|
||||
* proves nothing about any single key, and remembering it would skip weight
|
||||
* and blood pressure for a week. A re-probe that met no 277 is written down
|
||||
* too, so a cleared refusal is forgotten. A run that skipped remembered keys
|
||||
* and was accepted changes nothing.
|
||||
*/
|
||||
export function shouldPersistPlanLeg(opts: {
|
||||
gated: boolean;
|
||||
reprobed: boolean;
|
||||
acceptedAny: boolean;
|
||||
}): boolean {
|
||||
return opts.gated || opts.reprobed;
|
||||
if (opts.gated) return opts.acceptedAny;
|
||||
return opts.reprobed;
|
||||
}
|
||||
|
||||
// ── lifting parks an earlier build left ──────────────────────────────────────
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
planCheckedAtFor,
|
||||
planSkipFor,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
@@ -566,10 +567,16 @@ export async function syncUserSleep(
|
||||
),
|
||||
});
|
||||
summaries = plan.results;
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
if (
|
||||
shouldPersistPlanLeg({
|
||||
gated: plan.gated,
|
||||
reprobed,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
})
|
||||
) {
|
||||
await persistWithingsPlanLeg(userId, "sleepSummary", {
|
||||
unavailable: [...skip, ...plan.unavailable],
|
||||
checkedAt: now.toISOString(),
|
||||
checkedAt: planCheckedAtFor(sleepPlan, reprobed, now),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
+51
-15
@@ -18,10 +18,12 @@ import {
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
planCheckedAtFor,
|
||||
planSkipFor,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_MEASTYPES,
|
||||
WITHINGS_CORE_MEASTYPES,
|
||||
WITHINGS_PLAN_GATED_STATUS,
|
||||
type WithinPlanResult,
|
||||
} from "./api-plan";
|
||||
@@ -273,11 +275,17 @@ export async function syncUserMeasurements(
|
||||
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES);
|
||||
throw err;
|
||||
}
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
if (
|
||||
shouldPersistPlanLeg({
|
||||
gated: plan.gated,
|
||||
reprobed,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
await persistWithingsPlanLeg(userId, "measures", {
|
||||
unavailable: [...skip, ...plan.unavailable].sort((a, b) => a - b),
|
||||
checkedAt: now.toISOString(),
|
||||
checkedAt: planCheckedAtFor(planState.measures, reprobed, now),
|
||||
});
|
||||
} catch (err) {
|
||||
// Losing the memory only costs a refused round trip next run.
|
||||
@@ -290,21 +298,33 @@ export async function syncUserMeasurements(
|
||||
name: "withings.sync.plan_gated",
|
||||
details: {
|
||||
unavailable: plan.unavailable,
|
||||
unverified: plan.unverified,
|
||||
requests: plan.requests,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (plan.gated && !plan.acceptedAny) {
|
||||
// The plan refused every type asked for. Nothing was read, so the
|
||||
// watermark stays where it is (a later plan change re-reads the gap), and
|
||||
// nothing failed: the connection is healthy, the plan is the limit. The
|
||||
// settings card names what the plan withholds.
|
||||
await recordSyncSuccess(userId, "withings", {
|
||||
leg: WITHINGS_LEG_MEASURES,
|
||||
if (!plan.acceptedAny) {
|
||||
// Nothing was read: either plan memory left nothing to ask for, or every
|
||||
// request was refused. Every plan carries weight and blood pressure, so a
|
||||
// run where all of it was refused is a fault upstream, not a plan answer.
|
||||
// Nothing is remembered (above), the watermark stays where it is so the
|
||||
// next run reads the whole gap, and the run reports that it did not land.
|
||||
// It stays off the failure ledger like every other 277: a plan answer must
|
||||
// never climb the strike ladder or park the connection.
|
||||
getEvent()?.addWarning(
|
||||
requested.length === 0
|
||||
? "withings: plan memory left no measure type to request"
|
||||
: "withings: every measure request was refused with 277",
|
||||
);
|
||||
annotate({
|
||||
action: {
|
||||
name: "withings.sync.plan_nothing_read",
|
||||
details: { requested: requested.length, requests: plan.requests },
|
||||
},
|
||||
});
|
||||
return { imported: 0, failed: false };
|
||||
return { imported: 0, failed: true };
|
||||
}
|
||||
const fetched = plan.results;
|
||||
|
||||
@@ -486,11 +506,27 @@ export async function syncUserMeasurements(
|
||||
return { imported, failed: true };
|
||||
}
|
||||
|
||||
// Update last synced timestamp
|
||||
await prisma.withingsConnection.update({
|
||||
where: { userId },
|
||||
data: { lastSyncedAt: new Date() },
|
||||
});
|
||||
// Types the probe budget could not resolve were not read this run. Moving
|
||||
// the watermark past them would drop their readings in this window for
|
||||
// good, so it stays put until a run reads (or proves refused) every type.
|
||||
// Re-reading the accepted types is idempotent.
|
||||
// The same holds for a core type left out on the strength of plan memory:
|
||||
// that memory may be wrong, and the re-probe must find the window intact.
|
||||
const skippedCore = skip.filter((t) => WITHINGS_CORE_MEASTYPES.includes(t));
|
||||
const holdWatermark = plan.unverified.length > 0 || skippedCore.length > 0;
|
||||
if (holdWatermark) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "withings.sync.plan_watermark_held",
|
||||
details: { unverified: plan.unverified, skippedCore },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.withingsConnection.update({
|
||||
where: { userId },
|
||||
data: { lastSyncedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// Record success — even when measures.length === 0, completing the
|
||||
// round-trip is the signal "the connection is healthy". A user who
|
||||
|
||||
@@ -297,6 +297,81 @@ describe("a printed reference range, end to end", () => {
|
||||
expect(observations[0].valueQuantity?.unit).toBe("mg/dL");
|
||||
});
|
||||
|
||||
it("drops the printed text with the conversion record when the value is edited", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const marker = await prisma.biomarker.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 70,
|
||||
upperBound: 100,
|
||||
},
|
||||
});
|
||||
const PRINTED_MMOL = "3,9 - 5,5 mmol/l";
|
||||
const { POST } = await import("@/app/api/labs/route");
|
||||
const created = await POST(
|
||||
postReq({
|
||||
biomarkerId: marker.id,
|
||||
value: 5.6,
|
||||
unit: "mmol/L",
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.5,
|
||||
sourceReferenceText: PRINTED_MMOL,
|
||||
takenAt: "2026-07-03T08:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
const id = (await created.json()).data.id as string;
|
||||
|
||||
// The person types a new number in mg/dL, and the form echoes the old
|
||||
// printed text back with it.
|
||||
const { PUT } = await import("@/app/api/labs/[id]/route");
|
||||
const edited = await PUT(
|
||||
new NextRequest(`http://localhost/api/labs/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ value: 104, sourceReferenceText: PRINTED_MMOL }),
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect(edited.status).toBe(200);
|
||||
|
||||
const row = await prisma.labResult.findUniqueOrThrow({ where: { id } });
|
||||
expect(row.sourceUnit).toBeNull();
|
||||
expect(row.sourceValue).toBeNull();
|
||||
expect(row.sourceReferenceText).toBeNull();
|
||||
// The bounds were converted with the value and stay, in mg/dL.
|
||||
expect(row.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 6);
|
||||
expect(row.sourceReferenceHigh).toBeCloseTo(5.5 * 18.0182, 6);
|
||||
|
||||
// No surface shows mmol/L text beside the mg/dL numbers.
|
||||
const dto = await readDto("Glucose");
|
||||
expect(dto.converted).toBe(false);
|
||||
expect(dto.sourceReferenceText).toBeNull();
|
||||
expect(dto.printedReferenceText).toBeNull();
|
||||
expect(dto.referenceOrigin).toBe("source");
|
||||
expect(dto.referenceHigh).toBeCloseTo(5.5 * 18.0182, 6);
|
||||
|
||||
const { loadLabResults } =
|
||||
await import("@/lib/doctor-report/clinical-records");
|
||||
const labs = await loadLabResults(
|
||||
USER_ID,
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
new Date("2026-12-31T23:59:59.000Z"),
|
||||
);
|
||||
expect(labs?.[0].sourceReferenceText).toBeNull();
|
||||
|
||||
const { labObservations } = await import("@/lib/fhir/resources/labs");
|
||||
let seq = 0;
|
||||
const observations = labObservations(
|
||||
{ labResults: labs } as Parameters<typeof labObservations>[0],
|
||||
() => `obs-${++seq}`,
|
||||
);
|
||||
expect(observations[0].referenceRange?.[0]?.text).toBeUndefined();
|
||||
expect(observations[0].referenceRange?.[0]?.high?.unit).toBe("mg/dL");
|
||||
});
|
||||
|
||||
it("keeps a window it cannot read as text and leaves the catalog in force", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedMarker();
|
||||
|
||||
Reference in new issue
Block a user