mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
- apply repository formatting to reminder and scheduling updates - preserve verified occurrence behavior without semantic changes
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
/**
|
|
* Shared cached medications-list read for the two entry points:
|
|
*
|
|
* - `GET /api/medications` (the client cell's endpoint), and
|
|
* - the medications RSC wrapper (`src/app/medications/page.tsx`), which
|
|
* server-prefetches the same payload into a dehydrated TanStack cache so
|
|
* the first HTML paints the medication cards instead of skeletons-until-JS.
|
|
*
|
|
* Both read through the SAME `caches.medications` SWR cell (keyed on
|
|
* `userId`), so an RSC prefetch warms the API path and vice versa — the
|
|
* builder never runs twice for one user within the 60 s fresh TTL, and the
|
|
* write-invalidation semantics (`invalidateUserMedications`) cover both
|
|
* readers. Mirrors `src/lib/dashboard/snapshot-read.ts` for the dashboard.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { getMedicationCategories } from "@/lib/medication-category";
|
|
import {
|
|
computeDisplayDue,
|
|
OVERDUE_LOOKBACK_MS,
|
|
toResolvedSlotMark,
|
|
type ResolvedSlotMark,
|
|
} from "@/lib/medications/scheduling/next-due";
|
|
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
|
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
|
|
|
export type MedicationsListResult = Array<Record<string, unknown>>;
|
|
|
|
export async function buildMedicationsList(
|
|
userId: string,
|
|
userTz: string,
|
|
): Promise<MedicationsListResult> {
|
|
const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds(
|
|
new Date(),
|
|
userTz,
|
|
);
|
|
|
|
// v1.15.10 — the slots the user has already acted on near "now", so the
|
|
// next-due search can skip them. A resolved slot is a taken, deliberately
|
|
// skipped, or cron-auto-missed row. Bound the window to [today-start,
|
|
// today-end + 2d] — the next-due lookahead only needs the slots adjacent to
|
|
// now, and a tight window keeps the read cheap. The 60s list cache covers
|
|
// the rest.
|
|
const resolvedWindowEnd = new Date(
|
|
todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000,
|
|
);
|
|
// v1.16.4 — the open-overdue search reaches back as far as the widest
|
|
// band tail (weekly on-time + overdue), so the resolved-slot read must
|
|
// cover the same horizon or a long-resolved past slot would resurface
|
|
// as "overdue".
|
|
const resolvedWindowStart = new Date(
|
|
todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS,
|
|
);
|
|
|
|
const [
|
|
medications,
|
|
latestIntakes,
|
|
todayEvents,
|
|
resolvedEvents,
|
|
eraFloors,
|
|
usableStock,
|
|
inventoryCounts,
|
|
] = await Promise.all([
|
|
prisma.medication.findMany({
|
|
where: { userId },
|
|
include: { schedules: true },
|
|
orderBy: { createdAt: "desc" },
|
|
}),
|
|
prisma.medicationIntakeEvent.groupBy({
|
|
by: ["medicationId"],
|
|
// v1.7.0 sync — exclude tombstoned rows from the last-taken map.
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
skipped: false,
|
|
takenAt: { not: null },
|
|
},
|
|
_max: { takenAt: true },
|
|
}),
|
|
prisma.medicationIntakeEvent.groupBy({
|
|
by: ["medicationId"],
|
|
// v1.7.0 sync — exclude tombstoned rows from the today-count map.
|
|
// v1.16.9 — count only ACTIONED rows (taken or skipped). The
|
|
// dashboard projector mints pending rows for every slot of the
|
|
// day, so counting all rows made `todayEventCount` cover every
|
|
// passed dose after any dashboard visit — and the cards'
|
|
// overdue-pill suppression (`todayEventCount < passedDoseCount`)
|
|
// went dark nondeterministically for genuinely overdue doses.
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
|
|
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
|
},
|
|
_count: { id: 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 the ad-hoc shape
|
|
// (`scheduledFor === takenAt`) is detectable: such a row must not
|
|
// ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's
|
|
// genuinely-due 20:00 dose).
|
|
select: { medicationId: true, scheduledFor: true, takenAt: true },
|
|
}),
|
|
// v1.16.4 — current-era floor per medication: the newest revision's
|
|
// `validUntil` is where the LIVE schedule rows became valid. The
|
|
// open-overdue search mints from the live rows, so it must not reach
|
|
// past this boundary into a previous era's cadence.
|
|
prisma.medicationScheduleRevision.groupBy({
|
|
by: ["medicationId"],
|
|
// Superseded rows are audit records — a correction may have
|
|
// shortened the era, so the boundary reads only active rows.
|
|
where: { medication: { userId }, supersededByRevisionId: null },
|
|
_max: { validUntil: true },
|
|
}),
|
|
// v1.16.10 — usable stock per medication (one batched aggregate,
|
|
// not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE
|
|
// containers with units left — the same usable-container filter
|
|
// the GLP-1 details endpoint applies. Feeds the list payload's
|
|
// `stockUnitsRemaining` / `stockDosesRemaining` for the table view.
|
|
prisma.medicationInventoryItem.groupBy({
|
|
by: ["medicationId"],
|
|
where: {
|
|
userId,
|
|
state: { in: ["ACTIVE", "IN_USE"] },
|
|
unitsRemaining: { gt: 0 },
|
|
},
|
|
_sum: { unitsRemaining: true },
|
|
}),
|
|
// …and the any-state item count, so a medication whose containers
|
|
// are all used up / expired reads as stock 0 (tracking is ON, the
|
|
// supply ran out) instead of null (tracking off).
|
|
prisma.medicationInventoryItem.groupBy({
|
|
by: ["medicationId"],
|
|
where: { userId },
|
|
_count: { id: true },
|
|
}),
|
|
]);
|
|
|
|
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]);
|
|
}
|
|
|
|
const eraStartByMedId = new Map<string, Date>();
|
|
for (const f of eraFloors) {
|
|
if (f._max.validUntil)
|
|
eraStartByMedId.set(f.medicationId, f._max.validUntil);
|
|
}
|
|
|
|
const lastTakenAtByMedicationId = Object.fromEntries(
|
|
latestIntakes.map((entry) => [
|
|
entry.medicationId,
|
|
entry._max.takenAt ? entry._max.takenAt.toISOString() : null,
|
|
]),
|
|
);
|
|
// v1.7.0 SB-SCHED-3 — Date-typed last-intake map for the engine
|
|
// (rolling cadences re-anchor on it). Same groupBy as the ISO map.
|
|
const lastTakenAtDateByMedicationId = Object.fromEntries(
|
|
latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]),
|
|
);
|
|
const todayEventCountByMedId = Object.fromEntries(
|
|
todayEvents.map(
|
|
(entry: { medicationId: string; _count: { id: number } }) => [
|
|
entry.medicationId,
|
|
entry._count.id,
|
|
],
|
|
),
|
|
);
|
|
|
|
const usableUnitsByMedId = new Map<string, number>();
|
|
for (const entry of usableStock) {
|
|
usableUnitsByMedId.set(
|
|
entry.medicationId,
|
|
Number(entry._sum.unitsRemaining ?? 0),
|
|
);
|
|
}
|
|
const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId));
|
|
|
|
let categoryMap: Record<string, string> = {};
|
|
try {
|
|
categoryMap = await getMedicationCategories(medications.map((m) => m.id));
|
|
} catch {
|
|
getEvent()?.addWarning("Medication categories could not be loaded");
|
|
}
|
|
|
|
// v1.7.0 SB-SCHED-3 — server-computed next due instant. Time-derived;
|
|
// the list GET is cached 60 s on userId, so a 60 s staleness window is
|
|
// accepted here as it already is for `todayEventCount`.
|
|
const now = new Date();
|
|
|
|
return medications.map((m) => {
|
|
// v1.16.4 — an OPEN overdue slot (anchor passed, still inside its
|
|
// catch-up band, unresolved) surfaces FIRST with `nextDueOverdue:
|
|
// true`; only a closed or resolved band falls through to the future
|
|
// next-due. Keeps the card on the still-takeable dose instead of
|
|
// jumping ahead the minute the anchor passes.
|
|
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: lastTakenAtDateByMedicationId[m.id] ?? null,
|
|
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
|
|
eraStart: eraStartByMedId.get(m.id) ?? null,
|
|
});
|
|
const displaySchedule =
|
|
display?.scheduleId === undefined
|
|
? null
|
|
: (m.schedules.find((schedule) => schedule.id === display.scheduleId) ??
|
|
null);
|
|
// A prior-day rolling occurrence can remain the one authoritative
|
|
// actionable dose while today's same HH:mm window has already passed.
|
|
// The client window reducer uses this count to decide whether a passed
|
|
// local clock window is uncovered. Account for exactly that one rolling
|
|
// window here so GLP-1/take-all keep the server's prior scheduledFor
|
|
// instead of rebinding it onto today's date. Other sibling windows remain
|
|
// uncovered because the adjustment is only +1.
|
|
const carriesPriorRollingOccurrence =
|
|
display?.overdue === true &&
|
|
display.at.getTime() < todayStartUtc.getTime() &&
|
|
displaySchedule?.rollingIntervalDays !== null &&
|
|
displaySchedule?.rollingIntervalDays !== undefined;
|
|
// v1.16.10 — dose-derived stock for the table view. NULL when the
|
|
// medication has no inventory items at all (tracking off); 0 when
|
|
// tracking is on but every container is used up / expired.
|
|
const tracksInventory = trackedMedIds.has(m.id);
|
|
const stockUnitsRemaining = tracksInventory
|
|
? (usableUnitsByMedId.get(m.id) ?? 0)
|
|
: null;
|
|
const stockDosesRemaining =
|
|
stockUnitsRemaining === null
|
|
? null
|
|
: Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1));
|
|
return {
|
|
...m,
|
|
// v1.16.12 — Decimal → number so the wire stays a JSON number, not
|
|
// the string Prisma would otherwise serialise a Decimal to.
|
|
unitsPerDose: Number(m.unitsPerDose),
|
|
category: categoryMap[m.id] ?? "OTHER",
|
|
// v1.32.25 — provenance echo. Surfacing the mirror source lets the
|
|
// web UI and an operator tell an externally-mirrored row (today only
|
|
// Apple Health) from a native HealthLog medication. NULL for a native
|
|
// medication.
|
|
externalSource: m.externalSource ?? null,
|
|
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
|
todayEventCount:
|
|
(todayEventCountByMedId[m.id] ?? 0) +
|
|
(carriesPriorRollingOccurrence ? 1 : 0),
|
|
nextDueAt: display ? display.at.toISOString() : null,
|
|
nextDueOverdue: display?.overdue ?? false,
|
|
nextDueScheduleId: display?.scheduleId ?? null,
|
|
stockUnitsRemaining,
|
|
stockDosesRemaining,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve + read the medications list through the SWR cache for an already
|
|
* authenticated user row. Shared by the API route and the RSC prefetch; the
|
|
* `User` row is in hand at both call sites, so there is no extra round-trip.
|
|
*/
|
|
export async function readMedicationsListCached(user: {
|
|
id: string;
|
|
timezone: string | null;
|
|
}): Promise<MedicationsListResult> {
|
|
const userTz = user.timezone || "Europe/Berlin";
|
|
return cachedSwr(
|
|
caches.medications as ServerCache<MedicationsListResult>,
|
|
user.id,
|
|
() => buildMedicationsList(user.id, userTz),
|
|
annotate,
|
|
);
|
|
}
|