mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge branch 'v1424/fixes' into release/v1.42.4
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) ─────────────── */
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user