mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(medications): keep an extra Apple Health dose off a slot that already holds one
A HealthKit dose event binds to a slot by window band, and a weekly schedule's band reaches a day either side of its slot. An extra dose logged the day after a scheduled one landed on that slot and overwrote it last-write-wins: the slot's time changed and the extra dose was stored nowhere. An external dose record whose band slot already carries a different take is now recorded as its own unscheduled dose. The dose history now lets the take stored on a slot claim it before an extra dose stored on its own instant, instead of following the order the rows were read in, so the extra dose reads as off-schedule.
This commit is contained in:
1 parent
cdd1e9c87f
commit
dad9a7778e
6 files changed
+602
-3
No files matched your search
@@ -1380,9 +1380,11 @@ describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events",
|
||||
});
|
||||
|
||||
it("stamps the externalId onto the pending slot row when the Apple dose converges by band", async () => {
|
||||
// Dedup pre-check first (empty), then the canonical-slot find returns
|
||||
// the worker-minted pending REMINDER row for the 19:00 slot.
|
||||
// Dedup pre-check first (empty), then the held-dose probe (no take on
|
||||
// the slot yet), then the canonical-slot find returns the worker-minted
|
||||
// pending REMINDER row for the 19:00 slot.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
resolveSlotInstantForWrite,
|
||||
slotHeldByAnotherDose,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
@@ -338,6 +339,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let duplicates = 0;
|
||||
// External dose records kept as their own unscheduled dose because their
|
||||
// band slot already carried a different one.
|
||||
let extraDosesUnscheduled = 0;
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
|
||||
@@ -443,6 +447,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// a band decision. Pending echoes / skips carry no decision
|
||||
// (undefined → the upsert leaves an existing USER_PIN untouched).
|
||||
let attributionSource: "AUTO" | "USER_PIN" | undefined;
|
||||
// Set when the band slot is held by a different dose and this take is
|
||||
// recorded unscheduled; it must not converge onto any slot row below.
|
||||
let heldByAnotherDose = false;
|
||||
if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
|
||||
canonicalSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
@@ -486,6 +493,26 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
canonicalSlot = attribution.slotInstant;
|
||||
attributionSource = "AUTO";
|
||||
// An external dose record (a HealthKit dose event) whose band slot
|
||||
// already carries a different recorded dose is an extra dose, not a
|
||||
// correction of that one. Converging would overwrite the slot's
|
||||
// take last-write-wins and the extra dose would exist nowhere; it is
|
||||
// recorded as its own unscheduled dose instead.
|
||||
if (
|
||||
canonicalSlot !== null &&
|
||||
entry.externalId !== undefined &&
|
||||
(await slotHeldByAnotherDose({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
canonicalSlot,
|
||||
takenAt: entry.takenAt as Date,
|
||||
externalId: entry.externalId,
|
||||
}))
|
||||
) {
|
||||
canonicalSlot = null;
|
||||
heldByAnotherDose = true;
|
||||
extraDosesUnscheduled += 1;
|
||||
}
|
||||
} else if (isExplicitSkip && entry.scheduledFor === undefined) {
|
||||
// v1.16.9 — a slot-less deliberate skip resolves by band
|
||||
// membership on the skip moment, exactly like a take does. The
|
||||
@@ -666,6 +693,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// late-morning dose consuming the evening slot). It records
|
||||
// standalone (ad-hoc) instead.
|
||||
const existingSlotRow =
|
||||
!heldByAnotherDose &&
|
||||
entry.scheduledFor !== undefined &&
|
||||
mayConvergeOntoSuppliedSlot({
|
||||
skipped: entry.skipped,
|
||||
@@ -878,6 +906,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
extra_doses_unscheduled: extraDosesUnscheduled,
|
||||
...(unstableShapes.length > 0
|
||||
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
|
||||
: {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* An extra (off-schedule) dose recorded in Apple Health must arrive as its own
|
||||
* unscheduled dose.
|
||||
*
|
||||
* The phone posts every HealthKit dose event to the bulk route with the
|
||||
* event's UUID as `externalId`, `scheduledFor` set to the event's scheduled
|
||||
* date or, for an extra dose that has none, its own time. A taken write binds
|
||||
* to a slot by window band, and a weekly schedule's band reaches a day either
|
||||
* side of its slot. An extra dose on the Wednesday after a Tuesday dose
|
||||
* therefore landed on the Tuesday slot and overwrote it: the Tuesday time was
|
||||
* replaced by the Wednesday one and the extra dose existed nowhere.
|
||||
*
|
||||
* Three shapes, each through the shipped `POST` and `GET` exports: an extra
|
||||
* dose far from any slot, one half an hour after a slot already taken, and one
|
||||
* on a day the schedule has no slot. Each asserts the stored rows AND the dose
|
||||
* history the app reads back, where the extra dose must show as off-schedule
|
||||
* and the slot keep its own dose.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const MIN = 60_000;
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function signedInUser(): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const suffix = `extra-dose-${counter++}`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: suffix,
|
||||
email: `${suffix}@example.test`,
|
||||
role: "USER",
|
||||
timezone: TZ,
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function seedMedication(
|
||||
userId: string,
|
||||
schedule: { timesOfDay: string[]; daysOfWeek: string | null },
|
||||
): Promise<string> {
|
||||
const med = await getPrismaClient().medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Magnesium",
|
||||
dose: "400mg",
|
||||
active: true,
|
||||
// Dose events from Apple Health only target a mirrored medication.
|
||||
externalSource: "APPLE_HEALTH",
|
||||
externalId: `hk-medication-${userId}`,
|
||||
startsOn: new Date(Date.now() - 60 * DAY_MS),
|
||||
// The ledger expects slots only from the medication's creation on.
|
||||
createdAt: new Date(Date.now() - 60 * DAY_MS),
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: schedule.timesOfDay[0],
|
||||
windowEnd: schedule.timesOfDay[0],
|
||||
timesOfDay: schedule.timesOfDay,
|
||||
daysOfWeek: schedule.daysOfWeek,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return med.id;
|
||||
}
|
||||
|
||||
/** A local day at least two weeks back with the given weekday (0 = Sunday). */
|
||||
function pastLocalDay(weekday: number): Date {
|
||||
const day = new Date(Date.now() - 21 * DAY_MS);
|
||||
day.setUTCHours(12, 0, 0, 0);
|
||||
while (day.getUTCDay() !== weekday) {
|
||||
day.setTime(day.getTime() - DAY_MS);
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
interface BulkEntry {
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string;
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
/** The phone's shape: one HealthKit dose event per entry. */
|
||||
function appleDose(
|
||||
medicationId: string,
|
||||
takenAt: Date,
|
||||
externalId: string,
|
||||
scheduledFor: Date = takenAt,
|
||||
): BulkEntry {
|
||||
return {
|
||||
medicationId,
|
||||
scheduledFor: scheduledFor.toISOString(),
|
||||
takenAt: takenAt.toISOString(),
|
||||
externalId,
|
||||
};
|
||||
}
|
||||
|
||||
async function postBulk(entries: BulkEntry[]) {
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/medications/intake/bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
entries: entries.map((entry) => ({
|
||||
...entry,
|
||||
source: "APPLE_HEALTH",
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
data: { entries: Array<{ status: string; id?: string }> };
|
||||
};
|
||||
return body.data.entries;
|
||||
}
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
interface LedgerRow {
|
||||
kind: "slot" | "ad_hoc";
|
||||
at: string;
|
||||
status: string;
|
||||
intake: { id: string | null; takenAt: string | null } | null;
|
||||
}
|
||||
|
||||
async function readLedger(
|
||||
medicationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<LedgerRow[]> {
|
||||
const { GET } = await import("@/app/api/medications/[id]/dose-history/route");
|
||||
const search = new URLSearchParams({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
});
|
||||
const response = await (GET as Handler)(
|
||||
new NextRequest(
|
||||
`http://localhost/api/medications/${medicationId}/dose-history?${search}`,
|
||||
{ method: "GET" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: medicationId }) },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as { data: { rows: LedgerRow[] } };
|
||||
return body.data.rows;
|
||||
}
|
||||
|
||||
async function rowsOf(medicationId: string) {
|
||||
return getPrismaClient().medicationIntakeEvent.findMany({
|
||||
where: { medicationId, deletedAt: null },
|
||||
orderBy: { takenAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
function takenAtOf(row: LedgerRow | undefined): number | null {
|
||||
const taken = row?.intake?.takenAt;
|
||||
return taken ? new Date(taken).getTime() : null;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("an extra dose from Apple Health is stored as an unscheduled dose", () => {
|
||||
it("(a) far from any slot", async () => {
|
||||
const userId = await signedInUser();
|
||||
const medicationId = await seedMedication(userId, {
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
});
|
||||
const day = pastLocalDay(3);
|
||||
const slot = localHmAsUtc(day, TZ, 8, 0);
|
||||
const extraAt = localHmAsUtc(day, TZ, 15, 0);
|
||||
|
||||
const results = await postBulk([
|
||||
appleDose(medicationId, new Date(slot.getTime() + 5 * MIN), "hk-a-1"),
|
||||
appleDose(medicationId, extraAt, "hk-a-2"),
|
||||
]);
|
||||
expect(results.map((r) => r.status)).toEqual(["inserted", "inserted"]);
|
||||
|
||||
const rows = await rowsOf(medicationId);
|
||||
expect(rows).toHaveLength(2);
|
||||
const extra = rows.find((row) => row.externalId === "hk-a-2");
|
||||
expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime());
|
||||
expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime());
|
||||
|
||||
const ledger = await readLedger(
|
||||
medicationId,
|
||||
new Date(slot.getTime() - 6 * 3_600_000),
|
||||
new Date(slot.getTime() + 18 * 3_600_000),
|
||||
);
|
||||
const adHoc = ledger.filter((row) => row.kind === "ad_hoc");
|
||||
expect(adHoc).toHaveLength(1);
|
||||
expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime());
|
||||
const slotRow = ledger.find((row) => row.kind === "slot");
|
||||
expect(slotRow?.status).toBe("taken_on_time");
|
||||
});
|
||||
|
||||
it("(b) 30 minutes after a slot already taken", async () => {
|
||||
const userId = await signedInUser();
|
||||
const medicationId = await seedMedication(userId, {
|
||||
timesOfDay: ["22:00"],
|
||||
daysOfWeek: null,
|
||||
});
|
||||
const day = pastLocalDay(2);
|
||||
const slot = localHmAsUtc(day, TZ, 22, 0);
|
||||
const ownAt = new Date(slot.getTime() + 2 * MIN);
|
||||
const extraAt = new Date(slot.getTime() + 30 * MIN);
|
||||
|
||||
// The slot's own dose first, then the extra one in a later sync.
|
||||
expect(
|
||||
(await postBulk([appleDose(medicationId, ownAt, "hk-b-1", slot)])).map(
|
||||
(r) => r.status,
|
||||
),
|
||||
).toEqual(["inserted"]);
|
||||
expect(
|
||||
(await postBulk([appleDose(medicationId, extraAt, "hk-b-2")])).map(
|
||||
(r) => r.status,
|
||||
),
|
||||
).toEqual(["inserted"]);
|
||||
|
||||
const rows = await rowsOf(medicationId);
|
||||
expect(rows).toHaveLength(2);
|
||||
const own = rows.find((row) => row.externalId === "hk-b-1");
|
||||
const extra = rows.find((row) => row.externalId === "hk-b-2");
|
||||
// The slot keeps its own dose, time unchanged.
|
||||
expect(own?.scheduledFor.getTime()).toBe(slot.getTime());
|
||||
expect(own?.takenAt?.getTime()).toBe(ownAt.getTime());
|
||||
// The extra dose sits on its own instant.
|
||||
expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime());
|
||||
expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime());
|
||||
|
||||
const ledger = await readLedger(
|
||||
medicationId,
|
||||
new Date(slot.getTime() - 6 * 3_600_000),
|
||||
new Date(slot.getTime() + 6 * 3_600_000),
|
||||
);
|
||||
const slotRow = ledger.find((row) => row.kind === "slot");
|
||||
expect(slotRow?.status).toBe("taken_on_time");
|
||||
expect(takenAtOf(slotRow)).toBe(ownAt.getTime());
|
||||
const adHoc = ledger.filter((row) => row.kind === "ad_hoc");
|
||||
expect(adHoc).toHaveLength(1);
|
||||
expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime());
|
||||
});
|
||||
|
||||
it("(b2) a re-sync of the same dose event stays one dose", async () => {
|
||||
const userId = await signedInUser();
|
||||
const medicationId = await seedMedication(userId, {
|
||||
timesOfDay: ["22:00"],
|
||||
daysOfWeek: null,
|
||||
});
|
||||
const slot = localHmAsUtc(pastLocalDay(2), TZ, 22, 0);
|
||||
const ownAt = new Date(slot.getTime() + 2 * MIN);
|
||||
|
||||
await postBulk([appleDose(medicationId, ownAt, "hk-b2-1", slot)]);
|
||||
const again = await postBulk([
|
||||
appleDose(medicationId, ownAt, "hk-b2-1", slot),
|
||||
]);
|
||||
expect(again.map((r) => r.status)).toEqual(["duplicate"]);
|
||||
expect(await rowsOf(medicationId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("(c) on a day without a slot, next to the weekly slot the day before", async () => {
|
||||
const userId = await signedInUser();
|
||||
// Tuesdays and Fridays at 22:00.
|
||||
const medicationId = await seedMedication(userId, {
|
||||
timesOfDay: ["22:00"],
|
||||
daysOfWeek: "2,5",
|
||||
});
|
||||
const tuesday = pastLocalDay(2);
|
||||
const wednesday = new Date(tuesday.getTime() + DAY_MS);
|
||||
const tuesdaySlot = localHmAsUtc(tuesday, TZ, 22, 0);
|
||||
const tuesdayDose = localHmAsUtc(tuesday, TZ, 20, 23);
|
||||
const wednesdayDose = localHmAsUtc(wednesday, TZ, 20, 45);
|
||||
|
||||
await postBulk([appleDose(medicationId, tuesdayDose, "hk-c-tue")]);
|
||||
const results = await postBulk([
|
||||
appleDose(medicationId, wednesdayDose, "hk-c-wed"),
|
||||
]);
|
||||
expect(results.map((r) => r.status)).toEqual(["inserted"]);
|
||||
|
||||
const rows = await rowsOf(medicationId);
|
||||
expect(rows).toHaveLength(2);
|
||||
const tue = rows.find((row) => row.externalId === "hk-c-tue");
|
||||
const wed = rows.find((row) => row.externalId === "hk-c-wed");
|
||||
// Tuesday keeps its own time.
|
||||
expect(tue?.scheduledFor.getTime()).toBe(tuesdaySlot.getTime());
|
||||
expect(tue?.takenAt?.getTime()).toBe(tuesdayDose.getTime());
|
||||
// Wednesday is its own unscheduled dose.
|
||||
expect(wed?.scheduledFor.getTime()).toBe(wednesdayDose.getTime());
|
||||
expect(wed?.takenAt?.getTime()).toBe(wednesdayDose.getTime());
|
||||
|
||||
const ledger = await readLedger(
|
||||
medicationId,
|
||||
new Date(tuesday.getTime() - 2 * DAY_MS),
|
||||
new Date(tuesday.getTime() + 2 * DAY_MS),
|
||||
);
|
||||
const tuesdayRow = ledger.find(
|
||||
(row) =>
|
||||
row.kind === "slot" &&
|
||||
new Date(row.at).getTime() === tuesdaySlot.getTime(),
|
||||
);
|
||||
expect(takenAtOf(tuesdayRow)).toBe(tuesdayDose.getTime());
|
||||
const adHoc = ledger.filter((row) => row.kind === "ad_hoc");
|
||||
expect(adHoc.map(takenAtOf)).toEqual([wednesdayDose.getTime()]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user