mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
buildMedsTodayBlock ended the day at local midnight plus 24 hours. On the day the clocks go back that stops an hour early, so a dose between 23:00 and midnight was neither projected nor counted in the doses tally. The exclusive end is now the next local day's start, as the digest loader already computes it.
253 lines
9.6 KiB
TypeScript
253 lines
9.6 KiB
TypeScript
/**
|
|
* Today's medication block for the dashboard hero + summary tally.
|
|
*
|
|
* `buildMedsTodayBlock` is the one shared builder behind every surface
|
|
* that answers "how is the medication day going?":
|
|
*
|
|
* 1. **Projection** — `projectTodayIntakesAndRecompute` idempotently
|
|
* mints the day's pending `MedicationIntakeEvent` rows for active
|
|
* schedules (same helper the intake route + dashboard summary use),
|
|
* so the tally below never under-counts a daily med whose rows the
|
|
* reminder worker has not minted yet.
|
|
* 2. **Tally** — one today-window event read produces
|
|
* `scheduledToday` / `takenToday` / `skippedToday` with the exact
|
|
* semantics the summary route's compliance tile always had
|
|
* (taken = `takenAt !== null && !skipped`; tombstones excluded).
|
|
* 3. **Next due** — the same four feeder reads +
|
|
* `computeDisplayDue` loop the medications list route runs
|
|
* (`src/app/api/medications/route.ts`), restricted to ACTIVE
|
|
* medications: latest non-skipped intake per med (rolling cadences
|
|
* re-anchor on it), resolved slots over the overdue-lookback
|
|
* horizon, and the current-era floor per med. The block surfaces
|
|
* the EARLIEST display-due across medications — an open overdue
|
|
* slot sits in the past, so it naturally wins over any future slot.
|
|
*
|
|
* The feeder reads are not lifted out of the medications list route
|
|
* because that route reuses the same rows for its own response fields
|
|
* (`lastTakenAt`, `todayEventCount`, per-card next-due); the clean
|
|
* shared seam is `computeDisplayDue` itself, which both call sites go
|
|
* through.
|
|
*
|
|
* Consumer contract (snapshot caching): `nextDueAt` / `nextDueOverdue`
|
|
* are computed at build time and may be served from a cache. A
|
|
* `nextDueAt` in the past with `nextDueOverdue: false` means the slot's
|
|
* anchor passed AFTER the block was built — render the plain day
|
|
* summary, never an overdue state, until a fresh block arrives.
|
|
*/
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
|
import {
|
|
computeDisplayDue,
|
|
OVERDUE_LOOKBACK_MS,
|
|
toResolvedSlotMark,
|
|
type ResolvedSlotMark,
|
|
} from "@/lib/medications/scheduling/next-due";
|
|
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
|
import {
|
|
TRACKED_INTAKE_EVENT_WHERE,
|
|
TRACKED_INTAKE_WHERE,
|
|
} from "@/lib/medications/intake-tracking";
|
|
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
|
|
|
|
export interface MedsTodayDueCandidate {
|
|
medicationId: string;
|
|
medicationName: string;
|
|
dueAt: string;
|
|
overdue: boolean;
|
|
/** Canonical attribution-band start for this slot (ISO8601). */
|
|
availableFrom: string;
|
|
}
|
|
|
|
export interface MedsTodayBlock {
|
|
/** Active medications (paused courses excluded). */
|
|
activeCount: number;
|
|
/** Intake-event rows scheduled in the user's local today (all states). */
|
|
scheduledToday: number;
|
|
/** Rows with `takenAt` set and not skipped. */
|
|
takenToday: number;
|
|
/** Rows the user deliberately skipped. */
|
|
skippedToday: number;
|
|
/**
|
|
* Earliest display-due instant across active medications (ISO8601),
|
|
* or null when nothing is due (no schedules, course ended, all PRN).
|
|
* May lie outside today — the consumer gates on the local day.
|
|
*/
|
|
nextDueAt: string | null;
|
|
/**
|
|
* True when `nextDueAt` is an OPEN overdue slot (anchor passed, still
|
|
* inside its catch-up band, unresolved). A cached `nextDueAt` in the
|
|
* past with `nextDueOverdue: false` must render as the plain summary,
|
|
* never as overdue — see the module doc.
|
|
*/
|
|
nextDueOverdue: boolean;
|
|
/** Name of the medication carrying `nextDueAt`; null when none due. */
|
|
nextDueMedicationName: string | null;
|
|
/**
|
|
* Id of the medication carrying `nextDueAt`; null when none due. Threaded
|
|
* through so a consumer (the Today digest's dose-window rail item) can
|
|
* deep-link straight to the overdue medication's card instead of the bare
|
|
* list.
|
|
*/
|
|
nextDueMedicationId: string | null;
|
|
/**
|
|
* Every active medication's current display-due candidate. Optional only so
|
|
* older cached snapshots remain valid; the live builder always emits it.
|
|
*/
|
|
dueCandidates?: MedsTodayDueCandidate[];
|
|
}
|
|
|
|
export async function buildMedsTodayBlock(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
userTz: string,
|
|
now: Date,
|
|
): Promise<MedsTodayBlock> {
|
|
const { start: todayStart, end: todayEnd } = getUserTodayBounds(now, userTz);
|
|
// The projector + tally read use an exclusive upper bound (`lt`),
|
|
// matching the summary route's window convention. `todayEnd` is the
|
|
// inclusive last millisecond of the real local day, so +1 ms is the next
|
|
// day's start: 23 or 25 hours on a DST day, where a fixed 24 h missed the
|
|
// last hour of the 25-hour day.
|
|
const todayEndExclusive = new Date(todayEnd.getTime() + 1);
|
|
|
|
// Projection FIRST so the tally read below sees the freshly minted
|
|
// pending rows. Idempotent — `skipDuplicates` + the
|
|
// `(userId, medicationId, scheduledFor, source)` unique index make a
|
|
// concurrent intake-route hit safe.
|
|
await projectTodayIntakesAndRecompute({
|
|
userId,
|
|
userTz,
|
|
todayStart,
|
|
todayEnd: todayEndExclusive,
|
|
});
|
|
|
|
// Resolved-slot window — same horizon the medications list route uses:
|
|
// back as far as the widest band tail (so a long-resolved past slot
|
|
// cannot resurface as overdue), forward two days for the lookahead.
|
|
const resolvedWindowStart = new Date(
|
|
todayStart.getTime() - OVERDUE_LOOKBACK_MS,
|
|
);
|
|
const resolvedWindowEnd = new Date(
|
|
todayEnd.getTime() + 2 * 24 * 60 * 60 * 1000,
|
|
);
|
|
|
|
const [medications, todayEvents, latestIntakes, resolvedEvents, eraFloors] =
|
|
await Promise.all([
|
|
// v1.39.1 (#1033) — a medication with intake tracking off has no
|
|
// place on the doses card: not counted, never the next due dose.
|
|
prisma.medication.findMany({
|
|
where: { userId, active: true, ...TRACKED_INTAKE_WHERE },
|
|
include: { schedules: true },
|
|
orderBy: { createdAt: "desc" },
|
|
}),
|
|
prisma.medicationIntakeEvent.findMany({
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
scheduledFor: { gte: todayStart, lt: todayEndExclusive },
|
|
...TRACKED_INTAKE_EVENT_WHERE,
|
|
},
|
|
select: { takenAt: true, skipped: true },
|
|
}),
|
|
prisma.medicationIntakeEvent.groupBy({
|
|
by: ["medicationId"],
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
skipped: false,
|
|
takenAt: { not: null },
|
|
},
|
|
_max: { takenAt: true },
|
|
}),
|
|
prisma.medicationIntakeEvent.findMany({
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
|
|
OR: [
|
|
{ takenAt: { not: null } },
|
|
{ skipped: true },
|
|
{ autoMissed: true },
|
|
],
|
|
},
|
|
// v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor
|
|
// === takenAt`) cannot ±6h-resolve a different slot.
|
|
select: { medicationId: true, scheduledFor: true, takenAt: true },
|
|
}),
|
|
// Current-era floor per medication: the newest revision's
|
|
// `validUntil` is where the LIVE schedule rows became valid; the
|
|
// open-overdue search must not reach past it into a previous
|
|
// era's cadence.
|
|
prisma.medicationScheduleRevision.groupBy({
|
|
by: ["medicationId"],
|
|
where: { medication: { userId }, supersededByRevisionId: null },
|
|
_max: { validUntil: true },
|
|
}),
|
|
]);
|
|
|
|
const takenToday = todayEvents.filter(
|
|
(e) => e.takenAt !== null && !e.skipped,
|
|
).length;
|
|
const skippedToday = todayEvents.filter((e) => e.skipped).length;
|
|
|
|
const lastTakenAtByMedId = new Map<string, Date | null>(
|
|
latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]),
|
|
);
|
|
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
|
|
for (const e of resolvedEvents) {
|
|
const mark = toResolvedSlotMark(e);
|
|
const list = resolvedSlotsByMedId.get(e.medicationId);
|
|
if (list) list.push(mark);
|
|
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
|
|
}
|
|
// The live era start per medication (see `live-era.ts`).
|
|
const eraStartByMedId = liveEraStartsByMedication(eraFloors);
|
|
|
|
const dueCandidates: MedsTodayDueCandidate[] = [];
|
|
for (const m of medications) {
|
|
const display = computeDisplayDue({
|
|
medication: {
|
|
id: m.id,
|
|
startsOn: m.startsOn,
|
|
endsOn: m.endsOn,
|
|
oneShot: m.oneShot,
|
|
createdAt: m.createdAt,
|
|
},
|
|
schedules: m.schedules,
|
|
now,
|
|
userTz,
|
|
lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null,
|
|
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
|
|
eraStart: eraStartByMedId.get(m.id) ?? null,
|
|
});
|
|
if (!display) continue;
|
|
dueCandidates.push({
|
|
medicationId: m.id,
|
|
medicationName: m.name,
|
|
dueAt: display.at.toISOString(),
|
|
overdue: display.overdue,
|
|
availableFrom: (display.availableFrom ?? display.at).toISOString(),
|
|
});
|
|
}
|
|
dueCandidates.sort(
|
|
(a, b) =>
|
|
Number(b.overdue) - Number(a.overdue) ||
|
|
a.availableFrom.localeCompare(b.availableFrom) ||
|
|
a.dueAt.localeCompare(b.dueAt) ||
|
|
a.medicationId.localeCompare(b.medicationId),
|
|
);
|
|
const next = dueCandidates[0] ?? null;
|
|
|
|
return {
|
|
activeCount: medications.length,
|
|
scheduledToday: todayEvents.length,
|
|
takenToday,
|
|
skippedToday,
|
|
nextDueAt: next?.dueAt ?? null,
|
|
nextDueOverdue: next?.overdue ?? false,
|
|
nextDueMedicationName: next?.medicationName ?? null,
|
|
nextDueMedicationId: next?.medicationId ?? null,
|
|
dueCandidates,
|
|
};
|
|
}
|