Merge branch 'v1424/fixes' into release/v1.42.4

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 12:23:55 +02:00
commit de40db7ad0
49 files changed
+2579 -269

No files matched your search

@@ -0,0 +1,114 @@
/**
* An extra dose next to a slot that already carries one.
*
* Two ends: the write path keeps a different external dose record off a slot
* that is already taken (`isAnotherDose`), and the read ledger lets the dose
* stored on the slot claim it before an extra one stored on its own instant,
* whatever order the rows were read in.
*/
import { describe, expect, it } from "vitest";
import { buildSlotBands, type SlotWindowInput } from "../attribution";
import { reconstructDoseHistory, type HistoryIntake } from "../dose-history";
import { SAME_DOSE_TOLERANCE_MS, isAnotherDose } from "../slot-upsert";
const MIN = 60_000;
const t0 = new Date("2026-06-09T20:00:00Z");
const plus = (minutes: number) => new Date(t0.getTime() + minutes * MIN);
describe("isAnotherDose", () => {
it("a slot with nothing taken is never held by another dose", () => {
expect(
isAnotherDose(
{ takenAt: null, externalId: null },
{ takenAt: plus(600), externalId: "hk-2" },
),
).toBe(false);
});
it("the same external record is the same dose", () => {
expect(
isAnotherDose(
{ takenAt: t0, externalId: "hk-1" },
{ takenAt: plus(600), externalId: "hk-1" },
),
).toBe(false);
});
it("two external records a day apart are two doses", () => {
expect(
isAnotherDose(
{ takenAt: t0, externalId: "hk-1" },
{ takenAt: plus(24 * 60 + 22), externalId: "hk-2" },
),
).toBe(true);
});
it("two external records within a minute are one dose entered twice", () => {
expect(
isAnotherDose(
{ takenAt: t0, externalId: "hk-1" },
{ takenAt: new Date(t0.getTime() + 30_000), externalId: "hk-2" },
),
).toBe(false);
});
it("a take with no external record merges within the tolerance only", () => {
const held = { takenAt: t0, externalId: null };
expect(
isAnotherDose(held, {
takenAt: new Date(t0.getTime() + SAME_DOSE_TOLERANCE_MS),
externalId: "hk-2",
}),
).toBe(false);
expect(isAnotherDose(held, { takenAt: plus(30), externalId: "hk-2" })).toBe(
true,
);
});
});
describe("reconstructDoseHistory: the slot's own dose claims the slot", () => {
const slot: SlotWindowInput = {
at: t0,
timeOfDay: "22:00",
onTimeStart: plus(-60),
onTimeEnd: plus(60),
lateGraceMs: 180 * MIN,
};
const bands = buildSlotBands([slot]);
const onSlot: HistoryIntake = {
id: "on-slot",
scheduledFor: t0,
takenAt: plus(5),
skipped: false,
};
const extra: HistoryIntake = {
id: "extra",
scheduledFor: plus(30),
takenAt: plus(30),
skipped: false,
};
it.each([
["slot row first", [onSlot, extra]],
["extra row first (newest scheduledFor first)", [extra, onSlot]],
])("%s", (_label, intakes) => {
const rows = reconstructDoseHistory(bands, intakes, plus(600), null);
const slotRow = rows.find((row) => row.kind === "slot");
const adHoc = rows.filter((row) => row.kind === "ad_hoc");
expect(slotRow?.intake?.id).toBe("on-slot");
expect(adHoc.map((row) => row.intake?.id)).toEqual(["extra"]);
});
it("among takes not stored on the slot, the nearer one claims it", () => {
const far: HistoryIntake = { ...extra, id: "far" };
const near: HistoryIntake = {
id: "near",
scheduledFor: plus(10),
takenAt: plus(10),
skipped: false,
};
const rows = reconstructDoseHistory(bands, [far, near], plus(600), null);
expect(rows.find((row) => row.kind === "slot")?.intake?.id).toBe("near");
});
});
+26 -1
View File
@@ -273,8 +273,33 @@ export function reconstructDoseHistory(
}
}
for (const i of taken) {
// The best fit claims a slot first, whatever order the caller read the rows
// in. A take the write path stored ON the slot it attributes to goes before
// one stored on its own instant (an extra dose recorded unscheduled), then
// on-time before late, then the nearer take. Processing in the caller's
// order (newest `scheduledFor` first on the history route) let an extra dose
// logged half an hour after the slot claim it and pushed the slot's own
// dose out as the off-schedule one.
const ranked = taken.map((i) => {
const matched = attributeIntakeToSlot(i.takenAt as Date, bands);
if (!matched) return { i, matched, key: [2, 0, 0] as const };
const storedOnSlot =
Math.abs(i.scheduledFor.getTime() - matched.band.at.getTime()) <=
ANCHOR_EPSILON_MS;
return {
i,
matched,
key: [
storedOnSlot ? 0 : 1,
matched.status === "on_time" ? 0 : 1,
Math.abs((i.takenAt as Date).getTime() - matched.band.at.getTime()),
] as const,
};
});
ranked.sort(
(a, b) => a.key[0] - b.key[0] || a.key[1] - b.key[1] || a.key[2] - b.key[2],
);
for (const { i, matched } of ranked) {
if (matched && !claim.has(matched.band)) {
claim.set(matched.band, {
intake: i,
@@ -746,6 +746,79 @@ export async function findPinConflict(input: {
});
}
/**
* How far apart two recorded takes of one slot may sit and still be the same
* dose logged twice (a tap in HealthLog and an entry in another app a few
* minutes later). Further apart, they are two doses.
*/
export const SAME_DOSE_TOLERANCE_MS = 15 * 60_000;
/**
* Is `held` (the take a slot already carries) a different dose from the
* incoming external dose record?
*
* Band attribution maps a take to the slot whose band contains it, and a
* weekly schedule's band reaches a day either side of the slot. Without this
* check an extra dose logged the day after a Tuesday dose landed on the
* Tuesday slot and overwrote it last-write-wins: the Tuesday time was
* replaced and the extra dose existed nowhere.
*
* - Nothing taken on the slot yet (pending, skipped): not another dose, the
* incoming take is the slot's dose.
* - The same external record: the same dose.
* - Two different external records: different doses, unless they sit within
* a minute of each other, which is a double entry rather than two doses.
* - Otherwise (the held take has no external record): the same dose within
* {@link SAME_DOSE_TOLERANCE_MS}, a different one beyond it.
*/
export function isAnotherDose(
held: { takenAt: Date | null; externalId: string | null },
incoming: { takenAt: Date; externalId: string | null },
): boolean {
if (held.takenAt === null) return false;
if (held.externalId !== null && held.externalId === incoming.externalId) {
return false;
}
const gap = Math.abs(held.takenAt.getTime() - incoming.takenAt.getTime());
if (held.externalId !== null && incoming.externalId !== null) {
return gap > 60_000;
}
return gap > SAME_DOSE_TOLERANCE_MS;
}
/**
* Does the slot at `canonicalSlot` already carry a recorded take that is a
* different dose from the incoming one (see {@link isAnotherDose})? The
* caller records the incoming take as its own unscheduled dose instead of
* converging onto the slot.
*/
export async function slotHeldByAnotherDose(input: {
userId: string;
medicationId: string;
canonicalSlot: Date;
takenAt: Date;
externalId: string | null;
client?: Pick<PrismaClient, "medicationIntakeEvent">;
}): Promise<boolean> {
const client = input.client ?? defaultPrisma;
const rows = await client.medicationIntakeEvent.findMany({
where: {
userId: input.userId,
medicationId: input.medicationId,
scheduledFor: input.canonicalSlot,
deletedAt: null,
takenAt: { not: null },
},
select: { takenAt: true, externalId: true },
});
return rows.some((row) =>
isAnotherDose(row, {
takenAt: input.takenAt,
externalId: input.externalId,
}),
);
}
export interface ResolveForcedSlotInput {
userId: string;
medicationId: string;
@@ -1,11 +1,11 @@
/**
* One key, one fetch. The visit form's link pickers read labs and illness
* episodes with their own page size (200 results, 100 episodes), the Labs
* list and the illness hook with theirs (500, the route's default 50). Under
* a shared key whichever read landed first served the other, so the picker
* could offer 50 episodes or the Labs list could render a 200-row page as
* the whole list. The picker reads carry their own keys, still under the
* domain roots so a lab or episode write evicts them.
* episodes in their own windows and pages, the Labs list and the illness hook
* with theirs. Under a shared key whichever read landed first served the
* other, so the picker could offer 50 episodes or the Labs list could render
* a picker page as the whole list. The picker reads carry their own keys,
* still under the domain roots so a lab or episode write evicts them, and
* each direction, split and term is its own key.
*/
import { describe, expect, it } from "vitest";
@@ -22,20 +22,36 @@ const LIST_PARAMS = {
describe("the link pickers' reads have keys of their own", () => {
it("labs: differs from the Labs list and sits under the lab root", () => {
const key = queryKeys.labResultsPicker(200);
const key = queryKeys.labResultsPicker("older", null, "");
expect(key).not.toEqual(queryKeys.labResultsList(LIST_PARAMS));
expect(key.slice(0, 1)).toEqual(queryKeys.labResults());
});
it("episodes: differs from the illness list and sits under the illness root", () => {
const key = queryKeys.illnessEpisodesPicker(100);
const key = queryKeys.illnessEpisodesPicker("older", null, "");
expect(key).not.toEqual(queryKeys.illnessEpisodes(true));
expect(key.slice(0, 1)).toEqual(queryKeys.illness());
});
it("a different page size is a different key", () => {
expect(queryKeys.labResultsPicker(200)).not.toEqual(
queryKeys.labResultsPicker(500),
it("a different direction, split or term is a different key", () => {
const base = queryKeys.labResultsPicker("older", "2019-05-17", "");
expect(base).not.toEqual(
queryKeys.labResultsPicker("newer", "2019-05-17", ""),
);
expect(base).not.toEqual(
queryKeys.labResultsPicker("older", "2020-01-01", ""),
);
expect(queryKeys.labResultsPicker("search", null, "a")).not.toEqual(
queryKeys.labResultsPicker("search", null, "b"),
);
});
it("documents and visits: under their roots", () => {
expect(
queryKeys.inboundDocumentVaultPicker("older", null, "").slice(0, 1),
).toEqual(["documents"]);
expect(queryKeys.encounterPicker("older", null, "").slice(0, 1)).toEqual(
queryKeys.encounters(),
);
});
});
+17 -5
View File
@@ -61,12 +61,24 @@ export const documentKeys = {
inboundDocumentPicker: (q: string) =>
["documents", "inbound", "picker", q] as const,
/**
* The whole vault as a link-picker option list (every page, walked at the
* list route's ceiling), shared by the visit and vaccination forms. Own
* segment because the shape is a flat document array, not a page.
* The vault as a link-picker option list, shared by the visit and
* vaccination forms: one paged direction from the record's date or a
* search. Own segment because the page shape is the picker's, not the
* library's.
*/
inboundDocumentVaultPicker: () =>
["documents", "inbound", "vault-picker"] as const,
inboundDocumentVaultPicker: (
direction: string,
pivot: string | null,
term: string,
) =>
[
"documents",
"inbound",
"vault-picker",
direction,
pivot ?? "",
term,
] as const,
/**
* Storage usage + effective limits (`GET /api/documents/inbound/usage`).
* Invalidated through the `["documents"]` prefix after every upload /
+7
View File
@@ -45,6 +45,13 @@ export const encounterKeys = {
status ?? null,
episodeId ?? null,
] as const,
/**
* The document sheet's visit picker: one paged direction from the
* document's date. Its own key because its page shape (offset steps inside
* a window) is not the list's.
*/
encounterPicker: (direction: string, pivot: string | null, term: string) =>
["encounters", "picker", direction, pivot ?? "", term] as const,
encounter: (id: string) => ["encounters", "detail", id] as const,
/**
* The procedure history for one filter. Under the `["encounters"]` root, so
+8 -4
View File
@@ -15,10 +15,14 @@ export const illnessKeys = {
illness: () => ["illness"] as const,
illnessEpisodes: (includeResolved: boolean) =>
["illness", "episodes", includeResolved] as const,
// The visit form's episode picker, with its own page size (the illness
// pages read the route's default); a shared key would serve one to the other.
illnessEpisodesPicker: (limit: number) =>
["illness", "episodes-picker", limit] as const,
// The visit form's episode picker: one paged direction from the visit's
// date or a search, with its own page shape; a shared key would serve one
// read to the other.
illnessEpisodesPicker: (
direction: string,
pivot: string | null,
term: string,
) => ["illness", "episodes-picker", direction, pivot ?? "", term] as const,
illnessEpisode: (id: string) => ["illness", "episode", id] as const,
illnessDayLog: (episodeId: string, date: string) =>
["illness", "day-log", episodeId, date] as const,
+6 -5
View File
@@ -36,11 +36,12 @@ export const labKeys = {
labAnalyteLink: (analyte: string) =>
["lab-results", "analyte-link", analyte] as const,
// The visit form's lab picker: newest results, its own page size. Its own
// key because the Labs list reads a different page size, and a shared key
// would serve one read's page to the other.
labResultsPicker: (limit: number) =>
["lab-results", "picker", limit] as const,
// The visit form's lab picker: one paged direction from the visit's date
// (`older` / `newer`) or a search across the whole history. Its own key
// because the Labs list reads other pages, and a shared key would serve
// one read's page to the other.
labResultsPicker: (direction: string, pivot: string | null, term: string) =>
["lab-results", "picker", direction, pivot ?? "", term] as const,
// v1.25 — paginated (offset) reading feed for the biomarker detail page.
// Distinct from `labResultsList` (single-page key) so the infinite-query
+5
View File
@@ -164,6 +164,11 @@ export const encounterListQuerySchema = z
*/
episodeId: id.optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
/**
* Rows to skip in each arm, for a picker that pages a long history in
* `limit`-sized steps inside a date window.
*/
offset: z.coerce.number().int().min(0).max(100_000).optional(),
})
.strict()
.refine((q) => !(q.from && q.to) || q.from <= q.to, {
+11
View File
@@ -136,6 +136,17 @@ export type IllnessEpisodeResolve = z.infer<typeof illnessEpisodeResolveSchema>;
export const illnessEpisodeListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).optional(),
includeResolved: z.union([z.literal("true"), z.literal("false")]).optional(),
/**
* Paging and a date window over the onset, for a picker that opens on the
* episodes around a visit and pages older or newer from there.
*/
offset: z.coerce.number().int().min(0).max(100_000).optional(),
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
/** Onset order; newest first by default. */
sortDir: z.enum(["asc", "desc"]).optional(),
/** Case-insensitive substring over the episode's label. */
q: z.string().trim().min(1).max(120).optional(),
});
/* ── day-log capture (one row per day of an episode) ─────────────── */
+5
View File
@@ -284,6 +284,11 @@ export const listLabResultsSchema = z.object({
biomarkerId: z.string().trim().min(1).max(64).optional(),
analyte: z.string().trim().min(1).max(120).optional(),
panel: z.string().trim().min(1).max(120).optional(),
/**
* Case-insensitive substring over the analyte and the panel name, so a
* picker can find one result in a long history without paging to it.
*/
q: z.string().trim().min(1).max(120).optional(),
from: z
.string()
.datetime()