mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(medications): schedules remember their history
Editing a schedule's times no longer rewrites the past. Each material change archives the outgoing cadence as a dated revision, and every band consumer — history, compliance, write attribution, the auto-miss cutoff — mints past periods from the schedule that was valid at the time, so a dose era taken at its then-correct times stays on time forever. The repair script can infer historical eras from consistent old anchors behind an explicit flag. The welcome tour scrolls its target into view, flips above it when space below runs out and falls back to a centred sheet on any viewport, so no step can strand its buttons below the fold. The avatar menu drops the about entry — that lives in settings.
This commit is contained in:
1 parent
5bfafa6f51
commit
bd78cd7f7a
26 files changed
+1806
-223
No files matched your search
@@ -115,3 +115,32 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
The script is idempotent: a second `--fix` run finds zero duplicate groups
|
||||
and changes nothing. Exit code is 0 on success (including a clean
|
||||
zero-findings run), non-zero on bad arguments or a fatal error.
|
||||
|
||||
## Era backfill (`--backfill-eras`, v1.16.3)
|
||||
|
||||
Schedule edits made before v1.16.3 replaced the schedule rows without
|
||||
archiving the old state, so history reads past days against the current
|
||||
times. Section 5 of the script infers the lost era from the recorded
|
||||
slot anchors:
|
||||
|
||||
- A medication qualifies when its anchor-shaped rows (user-tz `HH:mm` on
|
||||
a 5-minute grid) deviate from the **current** `times_of_day` for at
|
||||
least 7 consecutive recorded days before the current times first
|
||||
appear, and it has **no** existing `medication_schedule_revisions`
|
||||
row (re-runs are idempotent).
|
||||
- The proposal is one revision row: `valid_from` = the first deviating
|
||||
row's instant, `valid_until` = local midnight of the first day on the
|
||||
current times, payload = one `FREQ=DAILY` schedule carrying the
|
||||
observed old times.
|
||||
- A `starts_on` that postdates the first recorded row is flagged and
|
||||
pulled back to that row's instant.
|
||||
|
||||
The default run only **reports** the proposals. Apply them explicitly:
|
||||
|
||||
```bash
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
|
||||
```
|
||||
|
||||
Review the dry-run table first — the inference is a heuristic. A wrongly
|
||||
created revision can be deleted from `medication_schedule_revisions`
|
||||
without side effects (the read paths fall back to live-only minting).
|
||||
@@ -0,0 +1,37 @@
|
||||
-- v1.16.3 — schedule-revision archive (effective dating).
|
||||
--
|
||||
-- A medication whose schedule times are edited used to lose its history: the
|
||||
-- wholesale replace (deleteMany + create) left only the CURRENT rows, so
|
||||
-- bands for PAST days were minted from the new times — the entire old era
|
||||
-- read "unscheduled" and the new times' past slots read "missed".
|
||||
--
|
||||
-- This table archives each SUPERSEDED schedule state as one revision row
|
||||
-- covering `[valid_from, valid_until)`. The LIVE rows stay in
|
||||
-- `medication_schedules`; a revision never describes the current state, so
|
||||
-- `valid_until` is NOT NULL by construction. `payload` holds the full
|
||||
-- snapshot of the replaced schedule rows (timesOfDay, windows, cadence
|
||||
-- fields, doseWindows, label, dose, reminderGraceMinutes) — enough to
|
||||
-- rebuild the canonical schedule list for era minting.
|
||||
--
|
||||
-- `starts_on` stays the global floor: the first era begins at
|
||||
-- max(medication.created_at, starts_on).
|
||||
--
|
||||
-- Additive + non-destructive: a new empty table; no existing row changes.
|
||||
CREATE TABLE "medication_schedule_revisions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"valid_from" TIMESTAMPTZ(6) NOT NULL,
|
||||
"valid_until" TIMESTAMPTZ(6) NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "medication_schedule_revisions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "medication_schedule_revisions_medication_id_valid_from_idx"
|
||||
ON "medication_schedule_revisions"("medication_id", "valid_from");
|
||||
|
||||
ALTER TABLE "medication_schedule_revisions"
|
||||
ADD CONSTRAINT "medication_schedule_revisions_medication_id_fkey"
|
||||
FOREIGN KEY ("medication_id") REFERENCES "medications"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1335,6 +1335,12 @@ model Medication {
|
||||
/// on the linked MedicationIntakeEvent rows refresh the row inline;
|
||||
/// the read paths consume this instead of the per-event findMany.
|
||||
complianceRollups MedicationComplianceRollup[]
|
||||
/// v1.16.3 — archived schedule eras (effective dating). Superseded
|
||||
/// schedule states land here when a wholesale schedule replace changes
|
||||
/// cadence-relevant fields; the band minter segments historical minting
|
||||
/// ranges into eras so past days read against the schedule that was
|
||||
/// live THEN, not the current one.
|
||||
scheduleRevisions MedicationScheduleRevision[]
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1431,6 +1437,47 @@ model MedicationSchedule {
|
||||
@@map("medication_schedules")
|
||||
}
|
||||
|
||||
/// v1.16.3 — schedule-revision archive (effective dating). One row per
|
||||
/// SUPERSEDED schedule state of a medication: when the schedule set is
|
||||
/// wholesale-replaced with materially different cadence fields, the old
|
||||
/// rows are archived here as a single revision covering
|
||||
/// `[validFrom, validUntil)`. The LIVE rows stay in
|
||||
/// `medication_schedules` — a revision never describes the current
|
||||
/// state, so `validUntil` is always set (no open-ended rows).
|
||||
///
|
||||
/// The band minter segments any historical minting range into eras:
|
||||
/// each revision interval mints with ITS archived schedules, the
|
||||
/// remainder (from the newest `validUntil` onward) mints with the live
|
||||
/// rows. Era boundary rule: a slot belongs to the era its anchor lies
|
||||
/// in; an on-time/late tail may reach past the boundary, but no new
|
||||
/// anchor is minted beyond it.
|
||||
///
|
||||
/// `startsOn` stays the global floor for ALL eras: the first era begins
|
||||
/// at `max(medication.createdAt, startsOn)`; a times-only edit must
|
||||
/// never move `startsOn`.
|
||||
model MedicationScheduleRevision {
|
||||
id String @id @default(cuid())
|
||||
medicationId String @map("medication_id")
|
||||
/// Inclusive start of the era this archived state was live for.
|
||||
/// Chains from the previous revision's `validUntil`, or from
|
||||
/// `medication.createdAt` for the first revision.
|
||||
validFrom DateTime @map("valid_from") @db.Timestamptz(6)
|
||||
/// Exclusive end of the era — the instant the replace happened.
|
||||
validUntil DateTime @map("valid_until") @db.Timestamptz(6)
|
||||
/// Full snapshot of the superseded schedule rows: an array of
|
||||
/// `{ timesOfDay, windowStart, windowEnd, daysOfWeek, rrule,
|
||||
/// rollingIntervalDays, scheduleType, cyclicOnWeeks, cyclicOffWeeks,
|
||||
/// doseWindows, label, dose, reminderGraceMinutes }` objects — enough
|
||||
/// to rebuild a `CanonicalSchedule` list for era minting.
|
||||
payload Json
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([medicationId, validFrom])
|
||||
@@map("medication_schedule_revisions")
|
||||
}
|
||||
|
||||
enum IntakeSource {
|
||||
WEB
|
||||
API
|
||||
|
||||
@@ -38,6 +38,18 @@
|
||||
* anchors — they linger as phantom open doses. Repair: tombstone
|
||||
* with `--fix` (same soft-delete shape as defect 1).
|
||||
*
|
||||
* 5. ERA INFERENCE (v1.16.3) — medications edited BEFORE schedule
|
||||
* versioning existed lost their old era: history reads against the
|
||||
* current times and the old takes look off-schedule. When the
|
||||
* recorded slot anchors (user-tz HH:mm on a 5-minute grid) deviate
|
||||
* from the CURRENT `times_of_day` for >= 7 consecutive recorded days
|
||||
* before the current times first appear, the script proposes one
|
||||
* `medication_schedule_revisions` row (validFrom = first deviating
|
||||
* row, validUntil = first day on the current times). It also flags a
|
||||
* `starts_on` that postdates the first recorded row. Report always;
|
||||
* CREATE/UPDATE only with `--backfill-eras`. Medications that
|
||||
* already have a revision are skipped (idempotent).
|
||||
*
|
||||
* After any mutation the affected `(user, medication, day)` compliance
|
||||
* rollups are recomputed with the SAME slot-level DISTINCT aggregation the
|
||||
* shared helper runs — see `recomputeComplianceDay` below, a verbatim
|
||||
@@ -75,6 +87,9 @@
|
||||
* # apply
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
|
||||
*
|
||||
* # create the proposed schedule revisions + startsOn fixes (defect 5)
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
|
||||
*/
|
||||
import { Client, types } from "pg";
|
||||
|
||||
@@ -101,6 +116,8 @@ function utcParam(date: Date): string {
|
||||
interface CliOptions {
|
||||
fix: boolean;
|
||||
tombstoneImplausible: boolean;
|
||||
/** v1.16.3 — create the proposed schedule revisions / startsOn fixes. */
|
||||
backfillEras: boolean;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
@@ -108,6 +125,7 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = {
|
||||
fix: false,
|
||||
tombstoneImplausible: false,
|
||||
backfillEras: false,
|
||||
userId: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -116,6 +134,8 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
opts.fix = true;
|
||||
} else if (arg === "--tombstone-implausible") {
|
||||
opts.tombstoneImplausible = true;
|
||||
} else if (arg === "--backfill-eras") {
|
||||
opts.backfillEras = true;
|
||||
} else if (arg === "--user" && argv[i + 1]) {
|
||||
opts.userId = argv[i + 1];
|
||||
i += 1;
|
||||
@@ -374,6 +394,10 @@ interface Summary {
|
||||
windowsReconciled: number;
|
||||
staleAnchorRows: number;
|
||||
staleAnchorTombstoned: number;
|
||||
eraCandidates: number;
|
||||
eraRevisionsCreated: number;
|
||||
startsOnCandidates: number;
|
||||
startsOnAdjusted: number;
|
||||
rollupsRecomputed: number;
|
||||
}
|
||||
|
||||
@@ -429,6 +453,10 @@ async function main(): Promise<void> {
|
||||
windowsReconciled: 0,
|
||||
staleAnchorRows: 0,
|
||||
staleAnchorTombstoned: 0,
|
||||
eraCandidates: 0,
|
||||
eraRevisionsCreated: 0,
|
||||
startsOnCandidates: 0,
|
||||
startsOnAdjusted: 0,
|
||||
rollupsRecomputed: 0,
|
||||
};
|
||||
|
||||
@@ -807,13 +835,245 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 5. Recompute the affected compliance rollups so scheduled/taken
|
||||
// 5. Era inference (v1.16.3) — medications whose historical slot
|
||||
// anchors deviate from the CURRENT times_of_day for >= 7
|
||||
// consecutive recorded days before the current times appear: the
|
||||
// schedule was edited pre-versioning and the old era was lost.
|
||||
// Propose one MedicationScheduleRevision per medication
|
||||
// (validFrom = first deviating row, validUntil = first day on the
|
||||
// current times) plus a startsOn correction when startsOn
|
||||
// postdates the first recorded row. Report always; CREATE only
|
||||
// with --backfill-eras. Only medications with ZERO existing
|
||||
// revisions are considered, so the section is idempotent.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const eraRowsRes = await client.query<{
|
||||
user_id: string;
|
||||
medication_id: string;
|
||||
medication_name: string;
|
||||
created_at: Date;
|
||||
starts_on: Date | null;
|
||||
scheduled_for: Date;
|
||||
}>(
|
||||
`
|
||||
SELECT e."user_id", e."medication_id", m."name" AS medication_name,
|
||||
m."created_at", m."starts_on", e."scheduled_for"
|
||||
FROM "medication_intake_events" e
|
||||
JOIN "medications" m ON m."id" = e."medication_id"
|
||||
WHERE e."deleted_at" IS NULL
|
||||
AND (e."taken_at" IS NOT NULL OR e."skipped" OR e."auto_missed")
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "medication_schedule_revisions" r
|
||||
WHERE r."medication_id" = e."medication_id"
|
||||
)
|
||||
${userFilter}
|
||||
ORDER BY e."user_id", e."medication_id", e."scheduled_for"
|
||||
`,
|
||||
userParams,
|
||||
);
|
||||
|
||||
/** Per-medication actioned rows, chronological. */
|
||||
const byMedication = new Map<string, typeof eraRowsRes.rows>();
|
||||
for (const row of eraRowsRes.rows) {
|
||||
const list = byMedication.get(row.medication_id) ?? [];
|
||||
list.push(row);
|
||||
byMedication.set(row.medication_id, list);
|
||||
}
|
||||
|
||||
interface EraProposal {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
timesOfDay: string[];
|
||||
validFrom: Date;
|
||||
validUntil: Date;
|
||||
dayCount: number;
|
||||
}
|
||||
interface StartsOnProposal {
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
startsOn: Date;
|
||||
firstRow: Date;
|
||||
}
|
||||
const eraProposals: EraProposal[] = [];
|
||||
const startsOnProposals: StartsOnProposal[] = [];
|
||||
|
||||
for (const [medicationId, rows] of byMedication) {
|
||||
const tz = await userTimezone(rows[0].user_id);
|
||||
const anchors = await medicationAnchors(medicationId);
|
||||
if (anchors.size === 0) continue; // PRN / unscheduled — nothing to infer.
|
||||
|
||||
// startsOn AFTER the first recorded row — the course began earlier
|
||||
// than the configured start; propose pulling startsOn back.
|
||||
const startsOn = rows[0].starts_on;
|
||||
if (startsOn && startsOn.getTime() > rows[0].scheduled_for.getTime()) {
|
||||
startsOnProposals.push({
|
||||
medicationId,
|
||||
medicationName: rows[0].medication_name,
|
||||
startsOn,
|
||||
firstRow: rows[0].scheduled_for,
|
||||
});
|
||||
}
|
||||
|
||||
// Walk the recorded days chronologically. Only ANCHOR-shaped rows
|
||||
// count: a scheduled_for whose user-tz HH:mm sits on a 5-minute
|
||||
// grid (reminder-/projector-minted slot instants land on the
|
||||
// configured times; free-hand ad-hoc takes rarely do).
|
||||
const dayTimes = new Map<string, Set<string>>();
|
||||
const dayOrder: string[] = [];
|
||||
for (const row of rows) {
|
||||
const hhmm = hhmmInTz(row.scheduled_for, tz);
|
||||
if (!HHMM_RE.test(hhmm)) continue;
|
||||
if (Number(hhmm.slice(3)) % 5 !== 0) continue; // not anchor-shaped
|
||||
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
|
||||
let set = dayTimes.get(dayKey);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
dayTimes.set(dayKey, set);
|
||||
dayOrder.push(dayKey);
|
||||
}
|
||||
set.add(hhmm);
|
||||
}
|
||||
|
||||
// The leading run of recorded days whose anchor times ALL deviate
|
||||
// from the current schedule, ended by the first day that matches
|
||||
// the current times. Consecutive = consecutive RECORDED days; gaps
|
||||
// without rows (skipped logging) do not break the run.
|
||||
const offTimes = new Set<string>();
|
||||
let runDays = 0;
|
||||
let firstOffDay: string | null = null;
|
||||
let switchDay: string | null = null;
|
||||
for (const dayKey of dayOrder) {
|
||||
const times = [...(dayTimes.get(dayKey) ?? [])];
|
||||
if (times.length === 0) continue;
|
||||
const allOff = times.every((t) => !anchors.has(t));
|
||||
const anyOn = times.some((t) => anchors.has(t));
|
||||
if (allOff) {
|
||||
if (switchDay !== null) {
|
||||
// Off-anchor days AFTER current times appeared — mixed
|
||||
// history, not a clean era prefix. Bail for this medication.
|
||||
runDays = 0;
|
||||
break;
|
||||
}
|
||||
runDays += 1;
|
||||
if (firstOffDay === null) firstOffDay = dayKey;
|
||||
for (const t of times) offTimes.add(t);
|
||||
} else if (anyOn && switchDay === null) {
|
||||
switchDay = dayKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (runDays >= 7 && firstOffDay !== null && switchDay !== null) {
|
||||
const firstRow = rows.find(
|
||||
(r) => dayKeyForScheduledFor(r.scheduled_for, tz) === firstOffDay,
|
||||
);
|
||||
const validFrom = firstRow?.scheduled_for ?? rows[0].created_at;
|
||||
const validUntil = startOfDayUtcInTz(switchDay, safeTimezone(tz));
|
||||
eraProposals.push({
|
||||
userId: rows[0].user_id,
|
||||
medicationId,
|
||||
medicationName: rows[0].medication_name,
|
||||
timesOfDay: [...offTimes].sort(),
|
||||
validFrom,
|
||||
validUntil,
|
||||
dayCount: runDays,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
summary.eraCandidates = eraProposals.length;
|
||||
summary.startsOnCandidates = startsOnProposals.length;
|
||||
console.log(
|
||||
`\n${TAG} 5) era-inference candidates (>=7 consistent off-anchor days): ${eraProposals.length}`,
|
||||
);
|
||||
if (eraProposals.length > 0) {
|
||||
console.table(
|
||||
eraProposals.map((p) => ({
|
||||
medication: p.medicationName,
|
||||
oldTimes: p.timesOfDay.join(","),
|
||||
validFrom: p.validFrom.toISOString(),
|
||||
validUntil: p.validUntil.toISOString(),
|
||||
days: p.dayCount,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (startsOnProposals.length > 0) {
|
||||
console.log(
|
||||
`${TAG} startsOn postdates the first recorded row: ${startsOnProposals.length}`,
|
||||
);
|
||||
console.table(
|
||||
startsOnProposals.map((p) => ({
|
||||
medication: p.medicationName,
|
||||
startsOn: p.startsOn.toISOString(),
|
||||
firstRow: p.firstRow.toISOString(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.backfillEras) {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
for (const p of eraProposals) {
|
||||
const payload = JSON.stringify([
|
||||
{
|
||||
timesOfDay: p.timesOfDay,
|
||||
windowStart: p.timesOfDay[0],
|
||||
windowEnd: p.timesOfDay[p.timesOfDay.length - 1],
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
label: null,
|
||||
dose: null,
|
||||
reminderGraceMinutes: null,
|
||||
},
|
||||
]);
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO "medication_schedule_revisions"
|
||||
("id", "medication_id", "valid_from", "valid_until", "payload")
|
||||
SELECT $1, $2, $3::timestamptz, $4::timestamptz, $5::jsonb
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "medication_schedule_revisions"
|
||||
WHERE "medication_id" = $2
|
||||
)
|
||||
`,
|
||||
[
|
||||
randomUUID(),
|
||||
p.medicationId,
|
||||
p.validFrom.toISOString(),
|
||||
p.validUntil.toISOString(),
|
||||
payload,
|
||||
],
|
||||
);
|
||||
summary.eraRevisionsCreated += 1;
|
||||
}
|
||||
for (const p of startsOnProposals) {
|
||||
const res = await client.query(
|
||||
`
|
||||
UPDATE "medications"
|
||||
SET "starts_on" = $2::timestamp, "updated_at" = NOW()
|
||||
WHERE "id" = $1 AND "starts_on" = $3::timestamp
|
||||
`,
|
||||
[
|
||||
p.medicationId,
|
||||
utcParam(p.firstRow),
|
||||
utcParam(p.startsOn),
|
||||
],
|
||||
);
|
||||
summary.startsOnAdjusted += res.rowCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 6. Recompute the affected compliance rollups so scheduled/taken
|
||||
// counts self-correct. Same DISTINCT-slot SQL the shared helper
|
||||
// runs (see `recomputeComplianceDay` above).
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
if (opts.fix && daysToRecompute.size > 0) {
|
||||
console.log(
|
||||
`\n${TAG} 5) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
|
||||
`\n${TAG} 6) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
|
||||
);
|
||||
for (const key of daysToRecompute) {
|
||||
const [userId, medicationId, dayKey] = key.split("|");
|
||||
@@ -850,6 +1110,10 @@ async function main(): Promise<void> {
|
||||
console.log(` windows reconciled: ${summary.windowsReconciled}`);
|
||||
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
|
||||
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
|
||||
console.log(` era-inference candidates: ${summary.eraCandidates}`);
|
||||
console.log(` era revisions created: ${summary.eraRevisionsCreated}`);
|
||||
console.log(` startsOn candidates: ${summary.startsOnCandidates}`);
|
||||
console.log(` startsOn adjusted: ${summary.startsOnAdjusted}`);
|
||||
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,11 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id, active: true },
|
||||
include: { schedules: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware expected counts.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -261,7 +261,11 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
const categoryMap = await getMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
|
||||
@@ -1007,7 +1007,11 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
|
||||
@@ -13,8 +13,13 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationSchedule: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn(),
|
||||
},
|
||||
medicationScheduleRevision: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
@@ -101,6 +106,15 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationSchedule.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
// v1.16.3 — the schedule-replace branch archives the previous rows as a
|
||||
// revision when a cadence field changes; default to "no previous rows".
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationScheduleRevision.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
@@ -202,6 +216,125 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — schedule replace archives a revision (v1.16.3)", () => {
|
||||
const PREVIOUS_ROW = {
|
||||
id: "s1",
|
||||
medicationId: "m1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
label: null,
|
||||
dose: null,
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
createdAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
PREVIOUS_ROW,
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("archives the previous rows when the dose times change", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "09:00",
|
||||
windowEnd: "21:00",
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const calls = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls;
|
||||
expect(calls).toHaveLength(1);
|
||||
const data = calls[0][0].data as {
|
||||
medicationId: string;
|
||||
validFrom: Date;
|
||||
validUntil: Date;
|
||||
payload: Array<{ timesOfDay: string[] }>;
|
||||
};
|
||||
expect(data.medicationId).toBe("m1");
|
||||
// First revision chains from medication.createdAt.
|
||||
expect(data.validFrom.toISOString()).toBe("2026-05-01T08:00:00.000Z");
|
||||
expect(data.validUntil.getTime()).toBeLessThanOrEqual(Date.now());
|
||||
expect(data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]);
|
||||
});
|
||||
|
||||
it("does NOT archive a revision on a no-op echo of the same schedule", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
timesOfDay: ["19:00", "07:00"], // reordered — still no-op
|
||||
rrule: "FREQ=DAILY",
|
||||
label: "Renamed", // display-only — never a cadence change
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("chains validFrom from the newest existing revision", async () => {
|
||||
vi.mocked(prisma.medicationScheduleRevision.findFirst).mockResolvedValue({
|
||||
validUntil: new Date("2026-06-01T12:00:00.000Z"),
|
||||
} as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "10:00",
|
||||
windowEnd: "22:00",
|
||||
timesOfDay: ["10:00", "22:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const calls = vi.mocked(prisma.medicationScheduleRevision.create).mock
|
||||
.calls;
|
||||
expect(calls).toHaveLength(1);
|
||||
const data = calls[0][0].data as { validFrom: Date };
|
||||
expect(data.validFrom.toISOString()).toBe("2026-06-01T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — schedule replace migrates open slots", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -57,7 +57,11 @@ export const GET = apiHandler(
|
||||
where: { id },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware chips/tallies.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
@@ -126,6 +130,7 @@ export const GET = apiHandler(
|
||||
createdAt: med.createdAt,
|
||||
lastIntakeAt,
|
||||
timeZone: userTz,
|
||||
scheduleRevisions: med.scheduleRevisions,
|
||||
};
|
||||
|
||||
const timeline = buildCadenceTimeline(
|
||||
|
||||
@@ -246,7 +246,11 @@ export const GET = apiHandler(
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication) {
|
||||
|
||||
@@ -29,10 +29,10 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandFamily,
|
||||
type BandMinterMedication,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import { buildBandsForSchedulesWithEras } from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type DoseHistoryRow,
|
||||
@@ -123,7 +123,12 @@ export const GET = apiHandler(
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras: past days mint against the
|
||||
// schedule that was live THEN.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
@@ -205,9 +210,10 @@ export const GET = apiHandler(
|
||||
.map((e) => e.takenAt as Date)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const groups = buildBandsForSchedules({
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication: bandMedication,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: medication.scheduleRevisions ?? [],
|
||||
ctx,
|
||||
userTz,
|
||||
range: { from, to },
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import {
|
||||
schedulesMateriallyDiffer,
|
||||
toRevisionPayloadEntry,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
@@ -174,7 +179,7 @@ export const PUT = apiHandler(
|
||||
if (guard) return guard;
|
||||
const existing = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { active: true },
|
||||
select: { active: true, createdAt: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Medication not found", 404);
|
||||
@@ -280,8 +285,151 @@ export const PUT = apiHandler(
|
||||
? { pausedAt: new Date() }
|
||||
: {};
|
||||
|
||||
// v1.16.3 — normalise the incoming schedules ONCE: the same values feed
|
||||
// the create payload below AND the revision compare/snapshot (so the
|
||||
// material-change gate sees exactly what will be persisted).
|
||||
const normalisedSchedules = schedules?.map((s) => {
|
||||
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
|
||||
// v1.7.0 — PRN carries no cadence, so never default it.
|
||||
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
|
||||
const isPrn = s.scheduleType === "PRN";
|
||||
const defaultedRrule =
|
||||
oneShot !== true &&
|
||||
!isPrn &&
|
||||
s.rrule === undefined &&
|
||||
s.rollingIntervalDays === undefined &&
|
||||
!hasLegacyDays
|
||||
? "FREQ=DAILY"
|
||||
: s.rrule;
|
||||
|
||||
// Invariant 3 — dual-write timesOfDay from windowStart.
|
||||
const effectiveTimesOfDay =
|
||||
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart];
|
||||
|
||||
// Invariant 4 — window / times consistency. See
|
||||
// `reconcileScheduleWindow`: a times-only edit that echoes the
|
||||
// stale window back gets the window pulled to the min/max of
|
||||
// the new times.
|
||||
const window = reconcileScheduleWindow(
|
||||
s.windowStart,
|
||||
s.windowEnd,
|
||||
effectiveTimesOfDay,
|
||||
);
|
||||
|
||||
const serializedDaysOfWeek = serializeScheduleRecurrence({
|
||||
daysOfWeek: s.daysOfWeek ?? [],
|
||||
intervalWeeks: s.intervalWeeks ?? 1,
|
||||
});
|
||||
|
||||
return {
|
||||
createData: {
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
daysOfWeek: serializedDaysOfWeek,
|
||||
// v1.5 first-class times-of-day.
|
||||
timesOfDay: effectiveTimesOfDay,
|
||||
...(s.reminderGraceMinutes !== undefined && {
|
||||
reminderGraceMinutes: s.reminderGraceMinutes,
|
||||
}),
|
||||
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
|
||||
...(s.rollingIntervalDays !== undefined && {
|
||||
rollingIntervalDays: s.rollingIntervalDays,
|
||||
}),
|
||||
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
|
||||
...(s.scheduleType !== undefined && {
|
||||
scheduleType: s.scheduleType,
|
||||
}),
|
||||
...(s.cyclicOnWeeks !== undefined && {
|
||||
cyclicOnWeeks: s.cyclicOnWeeks,
|
||||
}),
|
||||
...(s.cyclicOffWeeks !== undefined && {
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
}),
|
||||
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
|
||||
// replace re-creates the rows, so the column is re-written each
|
||||
// time; absent leaves it NULL (default ±1h derivation).
|
||||
...(s.doseWindows !== undefined && {
|
||||
doseWindows: s.doseWindows,
|
||||
}),
|
||||
},
|
||||
// Mirror of the row the create above will persist (defaults applied),
|
||||
// shaped for the cadence compare.
|
||||
snapshot: toRevisionPayloadEntry({
|
||||
timesOfDay: effectiveTimesOfDay,
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
daysOfWeek: serializedDaysOfWeek,
|
||||
rrule: defaultedRrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
scheduleType: s.scheduleType ?? "SCHEDULED",
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
doseWindows: s.doseWindows ?? null,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules) {
|
||||
if (schedules && normalisedSchedules) {
|
||||
// v1.16.3 — effective dating. Before the wholesale replace below wipes
|
||||
// the old rows, archive them as ONE revision covering
|
||||
// `[previous validUntil | medication.createdAt, now)` — but only when a
|
||||
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
|
||||
// tab echoing the same rows back) must not mint a phantom era.
|
||||
const previousRows = await prisma.medicationSchedule.findMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
const previousEntries = previousRows.map((row) =>
|
||||
toRevisionPayloadEntry({
|
||||
timesOfDay: row.timesOfDay,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
previousRows.length > 0 &&
|
||||
schedulesMateriallyDiffer(
|
||||
previousEntries,
|
||||
normalisedSchedules.map((n) => n.snapshot),
|
||||
)
|
||||
) {
|
||||
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
|
||||
where: { medicationId: id },
|
||||
orderBy: { validUntil: "desc" },
|
||||
select: { validUntil: true },
|
||||
});
|
||||
await prisma.medicationScheduleRevision.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
validFrom: lastRevision?.validUntil ?? existing.createdAt,
|
||||
validUntil: new Date(),
|
||||
payload: previousEntries as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.schedule.revision_archived",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { schedule_revision_rows: previousEntries.length },
|
||||
});
|
||||
}
|
||||
// A schedule replace invalidates the open slot anchors the projector /
|
||||
// reminder worker minted for the OLD times: a pending 08:00 row for a
|
||||
// medication that now doses at 20:00 would linger as a phantom slot,
|
||||
@@ -366,74 +514,9 @@ export const PUT = apiHandler(
|
||||
...(startsOn !== undefined && { startsOn }),
|
||||
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
...(schedules && {
|
||||
...(normalisedSchedules && {
|
||||
schedules: {
|
||||
create: schedules.map((s) => {
|
||||
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
|
||||
// v1.7.0 — PRN carries no cadence, so never default it.
|
||||
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
|
||||
const isPrn = s.scheduleType === "PRN";
|
||||
const defaultedRrule =
|
||||
oneShot !== true &&
|
||||
!isPrn &&
|
||||
s.rrule === undefined &&
|
||||
s.rollingIntervalDays === undefined &&
|
||||
!hasLegacyDays
|
||||
? "FREQ=DAILY"
|
||||
: s.rrule;
|
||||
|
||||
// Invariant 3 — dual-write timesOfDay from windowStart.
|
||||
const effectiveTimesOfDay =
|
||||
s.timesOfDay && s.timesOfDay.length > 0
|
||||
? s.timesOfDay
|
||||
: [s.windowStart];
|
||||
|
||||
// Invariant 4 — window / times consistency. See
|
||||
// `reconcileScheduleWindow`: a times-only edit that echoes the
|
||||
// stale window back gets the window pulled to the min/max of
|
||||
// the new times.
|
||||
const window = reconcileScheduleWindow(
|
||||
s.windowStart,
|
||||
s.windowEnd,
|
||||
effectiveTimesOfDay,
|
||||
);
|
||||
|
||||
return {
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
daysOfWeek: serializeScheduleRecurrence({
|
||||
daysOfWeek: s.daysOfWeek ?? [],
|
||||
intervalWeeks: s.intervalWeeks ?? 1,
|
||||
}),
|
||||
// v1.5 first-class times-of-day.
|
||||
timesOfDay: effectiveTimesOfDay,
|
||||
...(s.reminderGraceMinutes !== undefined && {
|
||||
reminderGraceMinutes: s.reminderGraceMinutes,
|
||||
}),
|
||||
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
|
||||
...(s.rollingIntervalDays !== undefined && {
|
||||
rollingIntervalDays: s.rollingIntervalDays,
|
||||
}),
|
||||
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
|
||||
...(s.scheduleType !== undefined && {
|
||||
scheduleType: s.scheduleType,
|
||||
}),
|
||||
...(s.cyclicOnWeeks !== undefined && {
|
||||
cyclicOnWeeks: s.cyclicOnWeeks,
|
||||
}),
|
||||
...(s.cyclicOffWeeks !== undefined && {
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
}),
|
||||
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
|
||||
// replace re-creates the rows, so the column is re-written each
|
||||
// time; absent leaves it NULL (default ±1h derivation).
|
||||
...(s.doseWindows !== undefined && {
|
||||
doseWindows: s.doseWindows,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
create: normalisedSchedules.map((n) => n.createData),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -258,7 +258,11 @@ async function buildScheduleAnchoredComplianceBuckets(
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware expected counts.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
|
||||
@@ -157,18 +157,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* "About HealthLog" links the signed-in `/settings/about`
|
||||
section (version, licences, update check). The slug also has
|
||||
its own entry at the end of the settings shell nav; this
|
||||
dropdown item keeps the historic discovery path alive. The
|
||||
public `/about` landing stays separate for signed-out
|
||||
visitors. */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/about" className="cursor-pointer">
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
{t("nav.about")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* The about section lives at the end of the settings shell nav;
|
||||
the avatar menu stays focused on account-level actions. */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{themeIcon}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeTooltipPosition } from "../tour";
|
||||
|
||||
/**
|
||||
* Pure-function contract tests for the tour popover position resolver.
|
||||
*
|
||||
* Production regression (v1.16.2): step 4 anchored to a nav item below
|
||||
* the fold and the resolved position put the whole card under the
|
||||
* viewport bottom — the Next button was unreachable and the tour was
|
||||
* stuck. The resolver now guarantees: every anchored placement keeps
|
||||
* the card's bottom edge ≥ 8 px above the viewport bottom, vertical
|
||||
* fallbacks prefer ABOVE the target, and an off-screen target (or a
|
||||
* target with no fitting placement at all) resolves to the `"sheet"`
|
||||
* fallback on every viewport width.
|
||||
*/
|
||||
|
||||
const VIEWPORT = { width: 1280, height: 800 };
|
||||
const SIZE = { width: 320, height: 220 };
|
||||
const PAD = 8;
|
||||
|
||||
function expectInsideViewport(pos: { top: number; left: number }) {
|
||||
expect(pos.top).toBeGreaterThanOrEqual(PAD);
|
||||
expect(pos.left).toBeGreaterThanOrEqual(PAD);
|
||||
expect(pos.top + SIZE.height).toBeLessThanOrEqual(VIEWPORT.height - PAD);
|
||||
expect(pos.left + SIZE.width).toBeLessThanOrEqual(VIEWPORT.width - PAD);
|
||||
}
|
||||
|
||||
describe("computeTooltipPosition", () => {
|
||||
it("returns a centred placement when there is no target rect", () => {
|
||||
const pos = computeTooltipPosition(null, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("center");
|
||||
expectInsideViewport(pos);
|
||||
});
|
||||
|
||||
it("keeps the requested bottom placement when it fits", () => {
|
||||
const rect = { top: 100, left: 400, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("bottom");
|
||||
expect(pos.top).toBeGreaterThan(rect.top + rect.height);
|
||||
expectInsideViewport(pos);
|
||||
});
|
||||
|
||||
it("flips a bottom placement ABOVE a target low in the viewport", () => {
|
||||
// Target visible but near the bottom edge: below it there is no
|
||||
// room for a 220 px card, above it there is.
|
||||
const rect = { top: 700, left: 400, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("top");
|
||||
expect(pos.top + SIZE.height).toBeLessThanOrEqual(rect.top);
|
||||
expectInsideViewport(pos);
|
||||
});
|
||||
|
||||
it("resolves to the sheet fallback when the target sits below the fold", () => {
|
||||
// The production bug: an anchor entirely under the viewport bottom.
|
||||
const rect = { top: 900, left: 16, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, "right", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("sheet");
|
||||
});
|
||||
|
||||
it("resolves to the sheet fallback when the target sits above the viewport", () => {
|
||||
const rect = { top: -300, left: 16, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("sheet");
|
||||
});
|
||||
|
||||
it("falls back from a side placement to the vertical axis when neither side fits", () => {
|
||||
// Target spans nearly the full width: no room left or right, but
|
||||
// room above — the resolver must not clamp the card over the
|
||||
// target or below the fold.
|
||||
const rect = { top: 600, left: 8, width: 1264, height: 60 };
|
||||
const pos = computeTooltipPosition(rect, "right", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("top");
|
||||
expect(pos.top + SIZE.height).toBeLessThanOrEqual(rect.top);
|
||||
expectInsideViewport(pos);
|
||||
});
|
||||
|
||||
it("resolves to the sheet fallback when no candidate fits anywhere", () => {
|
||||
// Target covers essentially the whole viewport — every anchored
|
||||
// candidate would land outside.
|
||||
const rect = { top: 8, left: 8, width: 1264, height: 784 };
|
||||
const pos = computeTooltipPosition(rect, "bottom", SIZE, VIEWPORT);
|
||||
expect(pos.placement).toBe("sheet");
|
||||
});
|
||||
|
||||
it("keeps the card bottom edge ≥ 8px above the viewport bottom for every anchored result", () => {
|
||||
const placements = ["top", "bottom", "left", "right", "center"] as const;
|
||||
for (const placement of placements) {
|
||||
for (let top = -200; top <= 1000; top += 40) {
|
||||
const rect = { top, left: 480, width: 200, height: 48 };
|
||||
const pos = computeTooltipPosition(rect, placement, SIZE, VIEWPORT);
|
||||
if (pos.placement === "sheet") continue; // sheet pins itself via CSS
|
||||
expect(pos.top + SIZE.height).toBeLessThanOrEqual(
|
||||
VIEWPORT.height - PAD,
|
||||
);
|
||||
expect(pos.top).toBeGreaterThanOrEqual(PAD);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -99,21 +99,30 @@ function measureTarget(targetId: string | null): SpotlightRect | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolved placement — the geometric placements plus the sheet fallback. */
|
||||
export type ResolvedPlacement = TourStop["placement"] | "sheet";
|
||||
|
||||
/**
|
||||
* Decide tooltip position. The intent is to sit beside the target
|
||||
* along the requested axis, but flip to the opposite edge if there
|
||||
* isn't enough room. Returns absolute viewport coordinates that the
|
||||
* UI uses with `position: fixed`. Falls back to centred when there
|
||||
* is no spotlight rect.
|
||||
* isn't enough room — vertical fallbacks prefer ABOVE the target so
|
||||
* the footer buttons can never be pushed under the viewport bottom.
|
||||
* Returns absolute viewport coordinates that the UI uses with
|
||||
* `position: fixed`. Falls back to centred when there is no spotlight
|
||||
* rect, and to `"sheet"` (caller renders a bottom sheet on EVERY
|
||||
* viewport width) when the target is off-screen or no candidate fits.
|
||||
*
|
||||
* Pure function of its inputs (viewport passed explicitly) so the
|
||||
* Node-environment vitest suite can exercise it without jsdom.
|
||||
*/
|
||||
function computeTooltipPosition(
|
||||
export function computeTooltipPosition(
|
||||
rect: SpotlightRect | null,
|
||||
placement: TourStop["placement"],
|
||||
tooltipSize: { width: number; height: number },
|
||||
): { top: number; left: number; placement: TourStop["placement"] } {
|
||||
if (typeof window === "undefined") return { top: 0, left: 0, placement };
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
viewport: { width: number; height: number },
|
||||
): { top: number; left: number; placement: ResolvedPlacement } {
|
||||
const vw = viewport.width;
|
||||
const vh = viewport.height;
|
||||
if (!rect) {
|
||||
return {
|
||||
top: Math.max(16, (vh - tooltipSize.height) / 2),
|
||||
@@ -121,6 +130,18 @@ function computeTooltipPosition(
|
||||
placement: "center",
|
||||
};
|
||||
}
|
||||
// Target entirely outside the visible viewport (e.g. an anchor below
|
||||
// the fold that scrollIntoView could not bring in, or mid-scroll):
|
||||
// every anchored candidate would point at nothing and risk landing
|
||||
// off-screen. Render the sheet fallback instead — on ALL viewports.
|
||||
const targetVisible =
|
||||
rect.top < vh - COLLISION_PAD &&
|
||||
rect.top + rect.height > COLLISION_PAD &&
|
||||
rect.left < vw - COLLISION_PAD &&
|
||||
rect.left + rect.width > COLLISION_PAD;
|
||||
if (!targetVisible) {
|
||||
return { top: 0, left: 0, placement: "sheet" };
|
||||
}
|
||||
const GAP = 12;
|
||||
const candidates: Array<{
|
||||
placement: TourStop["placement"];
|
||||
@@ -128,61 +149,52 @@ function computeTooltipPosition(
|
||||
left: number;
|
||||
}> = [];
|
||||
|
||||
if (placement === "bottom" || placement === "top" || placement === "center") {
|
||||
candidates.push({
|
||||
placement: "bottom",
|
||||
top: rect.top + rect.height + GAP,
|
||||
left: Math.min(
|
||||
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
|
||||
vw - tooltipSize.width - 16,
|
||||
),
|
||||
});
|
||||
candidates.push({
|
||||
placement: "top",
|
||||
top: rect.top - tooltipSize.height - GAP,
|
||||
left: Math.min(
|
||||
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
|
||||
vw - tooltipSize.width - 16,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (placement === "right" || placement === "left") {
|
||||
candidates.push({
|
||||
placement: "right",
|
||||
top: Math.min(
|
||||
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
|
||||
vh - tooltipSize.height - 16,
|
||||
),
|
||||
left: rect.left + rect.width + GAP,
|
||||
});
|
||||
candidates.push({
|
||||
placement: "left",
|
||||
top: Math.min(
|
||||
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
|
||||
vh - tooltipSize.height - 16,
|
||||
),
|
||||
left: rect.left - tooltipSize.width - GAP,
|
||||
});
|
||||
}
|
||||
// Pick the first candidate that fits the viewport; otherwise fall back to
|
||||
// centred. EVERY returned position is clamped into the viewport with a
|
||||
// collision padding — a candidate computed off a target near the edge must
|
||||
// never push the card (and its footer buttons) out of view.
|
||||
const clamp = (c: {
|
||||
placement: TourStop["placement"];
|
||||
top: number;
|
||||
left: number;
|
||||
}) => ({
|
||||
placement: c.placement,
|
||||
top: Math.min(
|
||||
Math.max(COLLISION_PAD, c.top),
|
||||
Math.max(COLLISION_PAD, vh - tooltipSize.height - COLLISION_PAD),
|
||||
),
|
||||
left: Math.min(
|
||||
Math.max(COLLISION_PAD, c.left),
|
||||
Math.max(COLLISION_PAD, vw - tooltipSize.width - COLLISION_PAD),
|
||||
),
|
||||
const centredLeft = Math.min(
|
||||
Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
|
||||
vw - tooltipSize.width - 16,
|
||||
);
|
||||
const vertical = (side: "top" | "bottom") => ({
|
||||
placement: side,
|
||||
top:
|
||||
side === "bottom"
|
||||
? rect.top + rect.height + GAP
|
||||
: rect.top - tooltipSize.height - GAP,
|
||||
left: centredLeft,
|
||||
});
|
||||
const horizontal = (side: "left" | "right") => ({
|
||||
placement: side,
|
||||
top: Math.min(
|
||||
Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
|
||||
vh - tooltipSize.height - 16,
|
||||
),
|
||||
left:
|
||||
side === "right"
|
||||
? rect.left + rect.width + GAP
|
||||
: rect.left - tooltipSize.width - GAP,
|
||||
});
|
||||
|
||||
if (placement === "bottom" || placement === "center") {
|
||||
candidates.push(vertical("bottom"), vertical("top"));
|
||||
} else if (placement === "top") {
|
||||
candidates.push(vertical("top"), vertical("bottom"));
|
||||
} else {
|
||||
// Side placements: requested side first, then the opposite side,
|
||||
// then the vertical axis — above before below, so a target low in
|
||||
// the viewport flips the card upwards instead of clipping its
|
||||
// footer under the fold.
|
||||
candidates.push(
|
||||
horizontal(placement),
|
||||
horizontal(placement === "right" ? "left" : "right"),
|
||||
vertical("top"),
|
||||
vertical("bottom"),
|
||||
);
|
||||
}
|
||||
// Pick the first candidate that fully fits the viewport — by
|
||||
// construction its bottom edge sits ≥ COLLISION_PAD above the
|
||||
// viewport bottom. If NONE fits (target visible but crowded on every
|
||||
// side), fall back to the sheet rather than clamping a card over an
|
||||
// arbitrary region: the sheet keeps the footer buttons reachable on
|
||||
// every viewport, which is the property the tour cannot lose.
|
||||
for (const c of candidates) {
|
||||
if (
|
||||
c.top >= COLLISION_PAD &&
|
||||
@@ -193,14 +205,7 @@ function computeTooltipPosition(
|
||||
return c;
|
||||
}
|
||||
}
|
||||
// None fits as-is: prefer the first requested candidate clamped into the
|
||||
// viewport (keeps the card near its target) over jumping to centre.
|
||||
if (candidates.length > 0) return clamp(candidates[0]);
|
||||
return clamp({
|
||||
placement: "center",
|
||||
top: (vh - tooltipSize.height) / 2,
|
||||
left: (vw - tooltipSize.width) / 2,
|
||||
});
|
||||
return { top: 0, left: 0, placement: "sheet" };
|
||||
}
|
||||
|
||||
const TOOLTIP_WIDTH = 320;
|
||||
@@ -237,6 +242,21 @@ export function OnboardingTour({
|
||||
setRect(measureTarget(stop.targetId));
|
||||
});
|
||||
};
|
||||
// Bring the target into view BEFORE the first measurement of this
|
||||
// step — anchors low on the page (e.g. the Settings nav item) can
|
||||
// sit entirely below the fold, and positioning against an
|
||||
// off-screen rect put the popover under the viewport bottom where
|
||||
// its Next button was unreachable. `block: "center"` keeps room on
|
||||
// both sides for the flipped placement; the rAF in `measure()`
|
||||
// runs after the instant scroll so we measure post-scroll
|
||||
// coordinates. Optional-chained: jsdom and older engines lack it.
|
||||
if (stop.targetId && typeof document !== "undefined") {
|
||||
document
|
||||
.querySelector<HTMLElement>(
|
||||
`[data-tour-id="${CSS.escape(stop.targetId)}"]`,
|
||||
)
|
||||
?.scrollIntoView?.({ block: "center", inline: "nearest" });
|
||||
}
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
window.addEventListener("scroll", measure, true);
|
||||
@@ -362,15 +382,23 @@ export function OnboardingTour({
|
||||
// next to a target on a ~400 px screen always ends up clipped.
|
||||
const viewportWidth =
|
||||
typeof window !== "undefined" ? window.innerWidth : 1024;
|
||||
const asSheet = viewportWidth < SHEET_BREAKPOINT;
|
||||
const viewportHeight =
|
||||
typeof window !== "undefined" ? window.innerHeight : 768;
|
||||
const tooltipWidth = Math.min(
|
||||
TOOLTIP_WIDTH,
|
||||
viewportWidth - COLLISION_PAD * 2,
|
||||
);
|
||||
const tooltipPos = computeTooltipPosition(rect, stop.placement, {
|
||||
width: tooltipWidth,
|
||||
height: TOOLTIP_HEIGHT,
|
||||
});
|
||||
const tooltipPos = computeTooltipPosition(
|
||||
rect,
|
||||
stop.placement,
|
||||
{ width: tooltipWidth, height: TOOLTIP_HEIGHT },
|
||||
{ width: viewportWidth, height: viewportHeight },
|
||||
);
|
||||
// The sheet renders on narrow viewports unconditionally AND on any
|
||||
// viewport when the resolver found no anchored placement that keeps
|
||||
// the whole card (footer included) inside the viewport.
|
||||
const asSheet =
|
||||
viewportWidth < SHEET_BREAKPOINT || tooltipPos.placement === "sheet";
|
||||
|
||||
// v1.4.33 F2 — onboarding overlay was a single full-viewport `<button>`
|
||||
// with a `clip-path` punching a hole around the spotlight. The clip-path
|
||||
@@ -518,11 +546,12 @@ export function OnboardingTour({
|
||||
data-placement={asSheet ? "sheet" : tooltipPos.placement}
|
||||
className={
|
||||
asSheet
|
||||
? // Bottom-sheet fallback for small viewports — full-width card
|
||||
// pinned above the bottom edge; no anchored positioning that
|
||||
// could clip the footer buttons off-screen.
|
||||
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 max-h-[70vh] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
: "bg-card border-border pointer-events-auto absolute max-h-[80vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
? // Bottom-sheet fallback — full-width on small screens,
|
||||
// centred above the bottom edge on wide ones (`mx-auto` +
|
||||
// `max-w`). Used on EVERY viewport when no anchored
|
||||
// placement keeps the footer buttons inside the viewport.
|
||||
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 mx-auto max-h-[70vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
: "bg-card border-border pointer-events-auto absolute max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
}
|
||||
style={
|
||||
asSheet
|
||||
@@ -531,6 +560,15 @@ export function OnboardingTour({
|
||||
top: `${tooltipPos.top}px`,
|
||||
left: `${tooltipPos.left}px`,
|
||||
width: `${tooltipWidth}px`,
|
||||
// Hard guarantee: the card's bottom edge stays ≥
|
||||
// COLLISION_PAD above the viewport bottom even when the
|
||||
// real content outgrows the TOOLTIP_HEIGHT estimate the
|
||||
// resolver positioned with — the card scrolls internally
|
||||
// instead of pushing its footer below the fold.
|
||||
maxHeight: `${Math.max(
|
||||
0,
|
||||
viewportHeight - tooltipPos.top - COLLISION_PAD,
|
||||
)}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -21,10 +21,14 @@
|
||||
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
type DoseWindowConfig,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import {
|
||||
buildBandsForSchedulesWithEras,
|
||||
occurrencesAcrossEras,
|
||||
type ScheduleRevisionLike,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
buildCadenceTimeline,
|
||||
type CadenceEngineContext,
|
||||
@@ -419,6 +423,13 @@ export interface ComplianceMedicationContext {
|
||||
createdAt: Date;
|
||||
lastIntakeAt: Date | null;
|
||||
timeZone: string;
|
||||
/**
|
||||
* v1.16.3 — archived schedule eras (`validFrom` ascending). When present,
|
||||
* every expected-slot expansion + band mint segments its range so a past
|
||||
* day counts/mints against the schedule that was live THEN. Optional:
|
||||
* callers without revisions keep the live-only expansion.
|
||||
*/
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -433,6 +444,8 @@ export function buildComplianceMedicationContext(
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
/** v1.16.3 — thread the archived eras when the bundle carries them. */
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
},
|
||||
lastIntakeAt: Date | null,
|
||||
timeZone: string,
|
||||
@@ -444,6 +457,7 @@ export function buildComplianceMedicationContext(
|
||||
createdAt: med.createdAt,
|
||||
lastIntakeAt,
|
||||
timeZone,
|
||||
...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -624,25 +638,28 @@ export function expectedSlotCountForDay(
|
||||
ctx: ComplianceMedicationContext,
|
||||
intakes?: ComplianceIntakeInstant[],
|
||||
): number {
|
||||
let count = 0;
|
||||
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily");
|
||||
const now = new Date();
|
||||
const retro =
|
||||
intakes && schedules.some((s) => s.rollingIntervalDays != null)
|
||||
? { intakeInstants: intakeInstantsAtOrBefore(intakes, now), now }
|
||||
: undefined;
|
||||
for (let i = 0; i < schedules.length; i++) {
|
||||
count += expandComplianceOccurrences(
|
||||
toCanonicalSchedule(schedules[i], `compliance-daily-${i}`),
|
||||
recurrenceCtx,
|
||||
dayStart,
|
||||
// v1.16.3 — era-aware: a past day counts the slots of the schedule that
|
||||
// was live THEN. With no revisions the single live era expands exactly
|
||||
// the per-schedule loop this used to run.
|
||||
return occurrencesAcrossEras(
|
||||
{
|
||||
from: dayStart,
|
||||
// occurrencesBetween is inclusive of both ends; subtract 1 ms so a
|
||||
// slot exactly at the next day's midnight doesn't double-count.
|
||||
new Date(dayEnd.getTime() - 1),
|
||||
retro,
|
||||
).length;
|
||||
}
|
||||
return count;
|
||||
to: new Date(dayEnd.getTime() - 1),
|
||||
},
|
||||
ctx.scheduleRevisions ?? [],
|
||||
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-daily-${i}`)),
|
||||
(schedule, eraFrom, eraTo) =>
|
||||
expandComplianceOccurrences(schedule, recurrenceCtx, eraFrom, eraTo, retro),
|
||||
{ oneShot: ctx.oneShot },
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -686,19 +703,17 @@ export function expectedSlotsBetween(
|
||||
intakes && schedules.some((s) => s.rollingIntervalDays != null)
|
||||
? { intakeInstants: intakeInstantsAtOrBefore(intakes, to), now: to }
|
||||
: undefined;
|
||||
const all: Occurrence[] = [];
|
||||
for (let i = 0; i < schedules.length; i++) {
|
||||
all.push(
|
||||
...expandComplianceOccurrences(
|
||||
toCanonicalSchedule(schedules[i], `compliance-slots-${i}`),
|
||||
recurrenceCtx,
|
||||
effectiveFrom,
|
||||
to,
|
||||
retro,
|
||||
),
|
||||
);
|
||||
}
|
||||
return all.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
if (effectiveFrom.getTime() > to.getTime()) return [];
|
||||
// v1.16.3 — era-aware: each archived era contributes the slots of ITS
|
||||
// schedules; the live rows cover only the range past the newest revision.
|
||||
return occurrencesAcrossEras(
|
||||
{ from: effectiveFrom, to },
|
||||
ctx.scheduleRevisions ?? [],
|
||||
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-slots-${i}`)),
|
||||
(schedule, eraFrom, eraTo) =>
|
||||
expandComplianceOccurrences(schedule, recurrenceCtx, eraFrom, eraTo, retro),
|
||||
{ oneShot: ctx.oneShot },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1261,9 +1276,11 @@ export function buildComplianceLedgerRows(
|
||||
to,
|
||||
);
|
||||
|
||||
const groups = buildBandsForSchedules({
|
||||
// v1.16.3 — era-aware mint: archived eras band with THEIR schedules.
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: ctx.scheduleRevisions ?? [],
|
||||
ctx: recurrenceCtx,
|
||||
userTz: ctx.timeZone,
|
||||
range: { from, to },
|
||||
@@ -1504,6 +1521,7 @@ export function calculateCompliance(
|
||||
createdAt: ctx.createdAt,
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
timeZone: ctx.timeZone,
|
||||
scheduleRevisions: ctx.scheduleRevisions,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -1853,6 +1871,7 @@ function buildTimelineForWindow(
|
||||
createdAt: ctx.createdAt,
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
timeZone: ctx.timeZone,
|
||||
scheduleRevisions: ctx.scheduleRevisions,
|
||||
};
|
||||
|
||||
const normalisedEvents: IntakeEventLike[] = events
|
||||
|
||||
@@ -265,7 +265,11 @@ export async function generateBloodPressureStatusForUser(
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
|
||||
@@ -914,7 +914,11 @@ export async function extractFeatures(
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (medications.length > 0) {
|
||||
|
||||
@@ -156,7 +156,11 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* v1.16.3 — schedule-era segmentation (effective dating).
|
||||
*
|
||||
* Pins the era-split engine: a schedule replace archives the old state as a
|
||||
* revision, and every historical surface mints past days against the
|
||||
* schedule that was live THEN. Covers the boundary rule (anchor decides era,
|
||||
* tails uncut), DST across an era boundary, a rolling-cadence era, the
|
||||
* zero-revision pass-through, the write-attribution era pick, and the
|
||||
* material-change gate the write path uses.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
attributeTakenToSlot,
|
||||
type AttributeIntakeMedication,
|
||||
} from "@/lib/medications/scheduling/attribute-intake";
|
||||
import { buildBandsForSchedules } from "@/lib/medications/scheduling/band-minter";
|
||||
import { reconstructDoseHistory } from "@/lib/medications/scheduling/dose-history";
|
||||
import type {
|
||||
CanonicalSchedule,
|
||||
RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import {
|
||||
buildBandsForSchedulesWithEras,
|
||||
canonicalSchedulesFromRevision,
|
||||
schedulesMateriallyDiffer,
|
||||
segmentRangeIntoEras,
|
||||
toRevisionPayloadEntry,
|
||||
type ScheduleRevisionEntry,
|
||||
type ScheduleRevisionLike,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
function canonical(partial: Partial<CanonicalSchedule>): CanonicalSchedule {
|
||||
return {
|
||||
id: "live-1",
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
timesOfDay: [],
|
||||
daysOfWeek: null,
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(partial: Partial<ScheduleRevisionEntry>): ScheduleRevisionEntry {
|
||||
return {
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
label: null,
|
||||
dose: null,
|
||||
reminderGraceMinutes: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const createdAt = new Date("2026-05-01T08:00:00.000Z");
|
||||
// The replace happened on 31 May at 10:00 UTC (12:00 Berlin).
|
||||
const replaceAt = new Date("2026-05-31T10:00:00.000Z");
|
||||
|
||||
const medication = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
const ctx: RecurrenceContext = {
|
||||
medication,
|
||||
timeZone: TZ,
|
||||
lastIntakeAt: null,
|
||||
};
|
||||
|
||||
const oldEraRevision: ScheduleRevisionLike = {
|
||||
id: "rev-1",
|
||||
validFrom: createdAt,
|
||||
validUntil: replaceAt,
|
||||
payload: [entry({})],
|
||||
};
|
||||
|
||||
const liveSchedule = canonical({
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
windowStart: "09:00",
|
||||
windowEnd: "21:00",
|
||||
});
|
||||
|
||||
describe("segmentRangeIntoEras", () => {
|
||||
it("returns a single live era when no revisions exist", () => {
|
||||
const range = {
|
||||
from: new Date("2026-05-10T00:00:00.000Z"),
|
||||
to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
};
|
||||
const eras = segmentRangeIntoEras(range, [], [liveSchedule]);
|
||||
expect(eras).toHaveLength(1);
|
||||
expect(eras[0].live).toBe(true);
|
||||
expect(eras[0].from).toEqual(range.from);
|
||||
expect(eras[0].to).toEqual(range.to);
|
||||
});
|
||||
|
||||
it("splits the range at validUntil with the revision schedules first", () => {
|
||||
const range = {
|
||||
from: new Date("2026-05-10T00:00:00.000Z"),
|
||||
to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
};
|
||||
const eras = segmentRangeIntoEras(range, [oldEraRevision], [liveSchedule]);
|
||||
expect(eras).toHaveLength(2);
|
||||
expect(eras[0].live).toBe(false);
|
||||
expect(eras[0].schedules[0].timesOfDay).toEqual(["07:00", "19:00"]);
|
||||
// Anchor-boundary rule: the archived era ends 1 ms short of validUntil.
|
||||
expect(eras[0].to.getTime()).toBe(replaceAt.getTime() - 1);
|
||||
expect(eras[1].live).toBe(true);
|
||||
expect(eras[1].from).toEqual(replaceAt);
|
||||
});
|
||||
|
||||
it("drops a revision era that does not intersect the range", () => {
|
||||
const range = {
|
||||
from: new Date("2026-06-01T00:00:00.000Z"),
|
||||
to: new Date("2026-06-10T00:00:00.000Z"),
|
||||
};
|
||||
const eras = segmentRangeIntoEras(range, [oldEraRevision], [liveSchedule]);
|
||||
expect(eras).toHaveLength(1);
|
||||
expect(eras[0].live).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBandsForSchedulesWithEras", () => {
|
||||
const range = {
|
||||
from: new Date("2026-05-20T00:00:00.000Z"),
|
||||
to: new Date("2026-06-05T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
function mintedBands() {
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: [liveSchedule],
|
||||
revisions: [oldEraRevision],
|
||||
ctx,
|
||||
userTz: TZ,
|
||||
range,
|
||||
now: range.to,
|
||||
});
|
||||
return groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : []));
|
||||
}
|
||||
|
||||
it("mints 07:00/19:00 before the boundary and 09:00/21:00 after", () => {
|
||||
const bands = mintedBands();
|
||||
const before = bands.filter((b) => b.at.getTime() < replaceAt.getTime());
|
||||
const after = bands.filter((b) => b.at.getTime() >= replaceAt.getTime());
|
||||
expect(before.length).toBeGreaterThan(0);
|
||||
expect(after.length).toBeGreaterThan(0);
|
||||
expect(new Set(before.map((b) => b.timeOfDay))).toEqual(
|
||||
new Set(["07:00", "19:00"]),
|
||||
);
|
||||
expect(new Set(after.map((b) => b.timeOfDay))).toEqual(
|
||||
new Set(["09:00", "21:00"]),
|
||||
);
|
||||
// The new times must not exist before the boundary at all.
|
||||
expect(
|
||||
before.some((b) => b.timeOfDay === "09:00" || b.timeOfDay === "21:00"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is a pass-through with zero revisions", () => {
|
||||
const direct = buildBandsForSchedules({
|
||||
medication,
|
||||
schedules: [liveSchedule],
|
||||
ctx,
|
||||
userTz: TZ,
|
||||
range,
|
||||
now: range.to,
|
||||
});
|
||||
const viaEras = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: [liveSchedule],
|
||||
revisions: [],
|
||||
ctx,
|
||||
userTz: TZ,
|
||||
range,
|
||||
now: range.to,
|
||||
});
|
||||
expect(viaEras).toEqual(direct);
|
||||
});
|
||||
|
||||
it("binds an old-era take on-time to the old 07:00 slot and keeps history compliance positive", () => {
|
||||
const bands = mintedBands();
|
||||
// A take at 07:05 Berlin (05:05 UTC) on 25 May — inside the old era.
|
||||
const takenAt = new Date("2026-05-25T05:05:00.000Z");
|
||||
const rows = reconstructDoseHistory(
|
||||
bands,
|
||||
[{ scheduledFor: takenAt, takenAt, skipped: false }],
|
||||
range.to,
|
||||
);
|
||||
const takenRows = rows.filter((r) => r.status === "taken_on_time");
|
||||
expect(takenRows).toHaveLength(1);
|
||||
expect(takenRows[0].timeOfDay).toBe("07:00");
|
||||
const taken = rows.filter(
|
||||
(r) => r.status === "taken_on_time" || r.status === "taken_late",
|
||||
).length;
|
||||
expect(taken).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps local slot times correct across a DST transition inside an era", () => {
|
||||
// Berlin springs forward on 2026-03-29. Old era covers the transition.
|
||||
const dstCreated = new Date("2026-03-20T08:00:00.000Z");
|
||||
const dstReplace = new Date("2026-04-02T10:00:00.000Z");
|
||||
const dstMed = { ...medication, createdAt: dstCreated };
|
||||
const dstCtx: RecurrenceContext = { ...ctx, medication: dstMed };
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication: dstMed,
|
||||
schedules: [liveSchedule],
|
||||
revisions: [
|
||||
{
|
||||
id: "rev-dst",
|
||||
validFrom: dstCreated,
|
||||
validUntil: dstReplace,
|
||||
payload: [entry({ timesOfDay: ["07:00"], windowStart: "07:00", windowEnd: "07:00" })],
|
||||
},
|
||||
],
|
||||
ctx: dstCtx,
|
||||
userTz: TZ,
|
||||
range: {
|
||||
from: new Date("2026-03-25T00:00:00.000Z"),
|
||||
to: new Date("2026-04-05T00:00:00.000Z"),
|
||||
},
|
||||
now: new Date("2026-04-05T00:00:00.000Z"),
|
||||
});
|
||||
const bands = groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : []));
|
||||
// 07:00 Berlin is 06:00 UTC before the transition, 05:00 UTC after.
|
||||
const mar28 = bands.find((b) => b.at.toISOString().startsWith("2026-03-28"));
|
||||
const mar30 = bands.find((b) => b.at.toISOString().startsWith("2026-03-30"));
|
||||
expect(mar28?.at.toISOString()).toBe("2026-03-28T06:00:00.000Z");
|
||||
expect(mar30?.at.toISOString()).toBe("2026-03-30T05:00:00.000Z");
|
||||
// After the replace the live 09:00/21:00 schedule mints (09:00 = 07:00Z).
|
||||
const apr03 = bands.filter((b) => b.at.toISOString().startsWith("2026-04-03"));
|
||||
expect(apr03.map((b) => b.timeOfDay).sort()).toEqual(["09:00", "21:00"]);
|
||||
});
|
||||
|
||||
it("mints a rolling era from the archived rolling schedule", () => {
|
||||
// Old era: weekly rolling injection. Live: daily oral.
|
||||
const rollingRevision: ScheduleRevisionLike = {
|
||||
id: "rev-roll",
|
||||
validFrom: createdAt,
|
||||
validUntil: replaceAt,
|
||||
payload: [
|
||||
entry({
|
||||
timesOfDay: ["08:00"],
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
rrule: null,
|
||||
rollingIntervalDays: 7,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const intakeInstants = [
|
||||
new Date("2026-05-05T06:00:00.000Z"),
|
||||
new Date("2026-05-12T06:10:00.000Z"),
|
||||
new Date("2026-05-19T05:55:00.000Z"),
|
||||
];
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: [liveSchedule],
|
||||
revisions: [rollingRevision],
|
||||
ctx,
|
||||
userTz: TZ,
|
||||
range: {
|
||||
from: new Date("2026-05-01T00:00:00.000Z"),
|
||||
to: new Date("2026-06-05T00:00:00.000Z"),
|
||||
},
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
intakeInstants,
|
||||
});
|
||||
const rollingGroup = groups.find((g) => g.scheduleId.startsWith("rev:rev-roll"));
|
||||
expect(rollingGroup?.family).toBe("weekly");
|
||||
// Each logged intake anchors one band inside the old era.
|
||||
expect(rollingGroup?.bands.length).toBeGreaterThanOrEqual(3);
|
||||
expect(
|
||||
rollingGroup?.bands.every((b) => b.at.getTime() < replaceAt.getTime()),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write-attribution era pick", () => {
|
||||
it("attributes an old takenAt against the era live at that instant", () => {
|
||||
const med: AttributeIntakeMedication = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
schedules: [
|
||||
{
|
||||
id: "live-1",
|
||||
windowStart: "09:00",
|
||||
windowEnd: "21:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
},
|
||||
],
|
||||
scheduleRevisions: [oldEraRevision],
|
||||
};
|
||||
// Editing a take back to 25 May 07:10 Berlin (05:10 UTC) — old era.
|
||||
const takenAt = new Date("2026-05-25T05:10:00.000Z");
|
||||
const result = attributeTakenToSlot({
|
||||
medication: med,
|
||||
userTz: TZ,
|
||||
takenAt,
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
});
|
||||
expect(result.slotInstant?.toISOString()).toBe("2026-05-25T05:00:00.000Z");
|
||||
expect(result.status).toBe("on_time");
|
||||
// The same instant against the live-only schedules would NOT be a
|
||||
// 07:00-slot match — the revision is what fixes the attribution.
|
||||
const liveOnly = attributeTakenToSlot({
|
||||
medication: { ...med, scheduleRevisions: [] },
|
||||
userTz: TZ,
|
||||
takenAt,
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
});
|
||||
expect(liveOnly.slotInstant?.toISOString()).not.toBe(
|
||||
"2026-05-25T05:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalSchedulesFromRevision", () => {
|
||||
it("rebuilds canonical schedules with synthetic ids and tolerates malformed payloads", () => {
|
||||
const schedules = canonicalSchedulesFromRevision(oldEraRevision);
|
||||
expect(schedules).toHaveLength(1);
|
||||
expect(schedules[0].id).toBe("rev:rev-1:0");
|
||||
expect(schedules[0].timesOfDay).toEqual(["07:00", "19:00"]);
|
||||
expect(
|
||||
canonicalSchedulesFromRevision({ ...oldEraRevision, payload: "garbage" }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
canonicalSchedulesFromRevision({ ...oldEraRevision, payload: [null] }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedulesMateriallyDiffer", () => {
|
||||
it("ignores label/dose changes and row order", () => {
|
||||
const a = [entry({ label: "Morgens" }), entry({ timesOfDay: ["12:00"] })];
|
||||
const b = [
|
||||
entry({ timesOfDay: ["12:00"], dose: "2" }),
|
||||
entry({ label: "Abends" }),
|
||||
];
|
||||
expect(schedulesMateriallyDiffer(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores timesOfDay ordering", () => {
|
||||
expect(
|
||||
schedulesMateriallyDiffer(
|
||||
[entry({ timesOfDay: ["19:00", "07:00"] })],
|
||||
[entry({ timesOfDay: ["07:00", "19:00"] })],
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a times change, a count change, and a doseWindows change", () => {
|
||||
expect(
|
||||
schedulesMateriallyDiffer(
|
||||
[entry({})],
|
||||
[entry({ timesOfDay: ["09:00", "21:00"] })],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(schedulesMateriallyDiffer([entry({})], [entry({}), entry({})])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
schedulesMateriallyDiffer(
|
||||
[entry({})],
|
||||
[
|
||||
entry({
|
||||
doseWindows: [{ timeOfDay: "07:00", start: "06:30", end: "08:30" }],
|
||||
}),
|
||||
],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips through toRevisionPayloadEntry", () => {
|
||||
const row = {
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED" as const,
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
label: "Alt",
|
||||
dose: null,
|
||||
reminderGraceMinutes: null,
|
||||
};
|
||||
expect(
|
||||
schedulesMateriallyDiffer([toRevisionPayloadEntry(row)], [entry({})]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,14 @@ import {
|
||||
type SlotBand,
|
||||
} from "@/lib/medications/scheduling/attribution";
|
||||
import {
|
||||
buildBandsForMedication,
|
||||
type BandMinterMedication,
|
||||
type DoseWindowConfig,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
import {
|
||||
buildBandsForSchedulesWithEras,
|
||||
type ScheduleRevisionLike,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
buildCanonicalSchedule,
|
||||
buildRecurrenceContext,
|
||||
@@ -46,6 +49,13 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
/** The medication projection the attributor reads (matches the write `select`). */
|
||||
export interface AttributeIntakeMedication extends WorkerMedicationRow {
|
||||
schedules: WorkerScheduleRow[];
|
||||
/**
|
||||
* v1.16.3 — archived schedule eras, `validFrom` ascending. When present
|
||||
* the day bands around a historical `takenAt` mint from the era that was
|
||||
* live THEN, so an edit of an old take attributes against the old times.
|
||||
* Optional: callers without revisions keep the live-only behaviour.
|
||||
*/
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
}
|
||||
|
||||
export interface AttributeTakenInput {
|
||||
@@ -145,25 +155,34 @@ export function buildMedicationDayBands(input: {
|
||||
to: new Date(input.around.getTime() + dayScaleReachDays * ONE_DAY_MS),
|
||||
};
|
||||
|
||||
const bands: SlotBand[] = [];
|
||||
let hasExpectedSlots = false;
|
||||
for (const schedule of canonicalSchedules) {
|
||||
const common = {
|
||||
// v1.16.3 — bands mint era-aware: a historical `around` inside an archived
|
||||
// era gets THAT era's schedules. With no revisions the wrapper passes
|
||||
// straight through to the live-only minter.
|
||||
const mintEras = (range: { from: Date; to: Date }) =>
|
||||
buildBandsForSchedulesWithEras({
|
||||
medication: bandMinterMedication,
|
||||
schedule,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: input.medication.scheduleRevisions ?? [],
|
||||
ctx,
|
||||
userTz: input.userTz,
|
||||
range,
|
||||
now: input.now ?? input.around,
|
||||
windowConfig: input.windowConfig,
|
||||
intakeInstants: input.intakeInstants,
|
||||
};
|
||||
let result = buildBandsForMedication({ ...common, range: minuteScaleRange });
|
||||
if (result.family === "weekly") {
|
||||
result = buildBandsForMedication({ ...common, range: dayScaleRange });
|
||||
}
|
||||
if (result.hasExpectedSlots) {
|
||||
});
|
||||
let groups = mintEras(minuteScaleRange);
|
||||
// Day-scale families re-mint over the wide range (the same per-schedule
|
||||
// narrow-then-wide pass as before, applied per group).
|
||||
if (groups.some((g) => g.family === "weekly")) {
|
||||
const wide = mintEras(dayScaleRange).filter((g) => g.family === "weekly");
|
||||
groups = [...groups.filter((g) => g.family !== "weekly"), ...wide];
|
||||
}
|
||||
const bands: SlotBand[] = [];
|
||||
let hasExpectedSlots = false;
|
||||
for (const g of groups) {
|
||||
if (g.hasExpectedSlots) {
|
||||
hasExpectedSlots = true;
|
||||
bands.push(...result.bands);
|
||||
bands.push(...g.bands);
|
||||
}
|
||||
}
|
||||
return { bands, hasExpectedSlots };
|
||||
|
||||
@@ -29,10 +29,11 @@
|
||||
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import { type BandMinterMedication } from "@/lib/medications/scheduling/band-minter";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
buildBandsForSchedulesWithEras,
|
||||
type ScheduleRevisionLike,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type HistoryIntake,
|
||||
@@ -125,6 +126,12 @@ export interface CadenceEngineContext {
|
||||
lastIntakeAt: Date | null;
|
||||
/** User IANA timezone. Required for the engine to apply HH:mm slots. */
|
||||
timeZone: string;
|
||||
/**
|
||||
* v1.16.3 — archived schedule eras (`validFrom` ascending). When present
|
||||
* the ledger-band consumers mint past days against the schedule that was
|
||||
* live THEN. Optional: callers without revisions keep live-only minting.
|
||||
*/
|
||||
scheduleRevisions?: ScheduleRevisionLike[];
|
||||
}
|
||||
|
||||
/** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */
|
||||
@@ -892,9 +899,10 @@ function missedFromLedger(
|
||||
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf)
|
||||
.map((e) => e.takenAt as Date)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
const groups = buildBandsForSchedules({
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: engineCtx.scheduleRevisions ?? [],
|
||||
ctx: recurrenceCtx,
|
||||
userTz,
|
||||
range: { from, to: asOf },
|
||||
|
||||
@@ -20,10 +20,8 @@
|
||||
*/
|
||||
|
||||
import type { SlotBand } from "./attribution";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
} from "./band-minter";
|
||||
import { type BandMinterMedication } from "./band-minter";
|
||||
import { buildBandsForSchedulesWithEras } from "./schedule-eras";
|
||||
import {
|
||||
buildCadenceTimeline,
|
||||
startOfLocalDay,
|
||||
@@ -234,9 +232,10 @@ function ledgerChipCounts(
|
||||
.map((e) => e.takenAt as Date)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const groups = buildBandsForSchedules({
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
medication,
|
||||
schedules: canonicalSchedules,
|
||||
revisions: engineCtx.scheduleRevisions ?? [],
|
||||
ctx: recurrenceCtx,
|
||||
userTz,
|
||||
range: { from, to: asOf },
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* v1.16.3 — schedule-era segmentation (effective dating).
|
||||
*
|
||||
* A wholesale schedule replace used to rewrite history: bands for PAST days
|
||||
* were minted from the CURRENT `medication_schedules` rows, so after a
|
||||
* times edit the whole old era read "missed at the new times". The write
|
||||
* path now archives each superseded schedule state as one
|
||||
* `MedicationScheduleRevision` row covering `[validFrom, validUntil)`; this
|
||||
* module turns those revisions into minting eras so every historical
|
||||
* surface (dose-history ledger, compliance tally, expected-slot counts,
|
||||
* write/edit attribution) reads a past day against the schedule that was
|
||||
* live THEN.
|
||||
*
|
||||
* Era rules:
|
||||
* - revisions are chained: `validFrom` = previous revision's `validUntil`
|
||||
* (or `medication.createdAt` for the first), `validUntil` = the replace
|
||||
* instant. The LIVE rows cover `[newest validUntil, ∞)`.
|
||||
* - boundary: a slot belongs to the era its ANCHOR lies in. Eras mint
|
||||
* with an inclusive sub-range capped 1 ms short of `validUntil`, so an
|
||||
* anchor exactly at the boundary mints in the NEXT era only. On-time /
|
||||
* late tails are NOT clipped — a band minted near the end of an era
|
||||
* keeps its full reach past the boundary.
|
||||
* - `startsOn` stays the global floor for all eras (the engine applies
|
||||
* it per occurrence); a times-only edit never moves it.
|
||||
*
|
||||
* Pure / synchronous like the band minter: revisions are pre-fetched by the
|
||||
* caller and threaded in — no DB access here.
|
||||
*/
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
type DoseWindowConfig,
|
||||
type ScheduleBandGroup,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import {
|
||||
SCHEDULE_TYPES,
|
||||
type CanonicalSchedule,
|
||||
type Occurrence,
|
||||
type RecurrenceContext,
|
||||
type ScheduleType,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
|
||||
/** The revision projection the era splitter reads (a Prisma row drops in). */
|
||||
export interface ScheduleRevisionLike {
|
||||
id: string;
|
||||
/** Inclusive start of the archived era. */
|
||||
validFrom: Date;
|
||||
/** Exclusive end of the archived era (the replace instant). */
|
||||
validUntil: Date;
|
||||
/** JSON array of {@link ScheduleRevisionEntry} snapshots. */
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* One archived schedule row inside a revision's `payload` array. The write
|
||||
* path snapshots every cadence-relevant column so the era splitter can
|
||||
* rebuild a full `CanonicalSchedule`; `label` / `dose` ride along for
|
||||
* display-only consumers but never participate in the material-change
|
||||
* compare.
|
||||
*/
|
||||
export interface ScheduleRevisionEntry {
|
||||
timesOfDay: string[];
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
daysOfWeek: string | null;
|
||||
rrule: string | null;
|
||||
rollingIntervalDays: number | null;
|
||||
scheduleType: ScheduleType;
|
||||
cyclicOnWeeks: number | null;
|
||||
cyclicOffWeeks: number | null;
|
||||
doseWindows: unknown;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
reminderGraceMinutes: number | null;
|
||||
}
|
||||
|
||||
/** One minting era: an inclusive `[from, to]` sub-range + its schedules. */
|
||||
export interface ScheduleEra {
|
||||
from: Date;
|
||||
to: Date;
|
||||
schedules: CanonicalSchedule[];
|
||||
/** True for the era backed by the live `medication_schedules` rows. */
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the `CanonicalSchedule` list a revision archived. Synthetic ids
|
||||
* (`rev:<revisionId>:<idx>`) keep the engine occurrence/group plumbing
|
||||
* stable without colliding with live schedule ids. Defensive parsing: a
|
||||
* malformed payload entry degrades to an empty/cadence-less schedule the
|
||||
* band minter already treats as "no slot machinery" rather than throwing
|
||||
* on a read path.
|
||||
*/
|
||||
export function canonicalSchedulesFromRevision(
|
||||
revision: ScheduleRevisionLike,
|
||||
options?: { oneShot?: boolean },
|
||||
): CanonicalSchedule[] {
|
||||
if (!Array.isArray(revision.payload)) return [];
|
||||
return revision.payload.map((raw, idx) => {
|
||||
const e = (raw ?? {}) as Partial<ScheduleRevisionEntry>;
|
||||
const base: CanonicalSchedule = {
|
||||
id: `rev:${revision.id}:${idx}`,
|
||||
rrule: typeof e.rrule === "string" ? e.rrule : null,
|
||||
rollingIntervalDays:
|
||||
typeof e.rollingIntervalDays === "number" ? e.rollingIntervalDays : null,
|
||||
timesOfDay: Array.isArray(e.timesOfDay)
|
||||
? e.timesOfDay.filter((t): t is string => typeof t === "string")
|
||||
: [],
|
||||
daysOfWeek: typeof e.daysOfWeek === "string" ? e.daysOfWeek : null,
|
||||
windowStart: typeof e.windowStart === "string" ? e.windowStart : "08:00",
|
||||
windowEnd: typeof e.windowEnd === "string" ? e.windowEnd : "08:00",
|
||||
reminderGraceMinutes:
|
||||
typeof e.reminderGraceMinutes === "number"
|
||||
? e.reminderGraceMinutes
|
||||
: null,
|
||||
scheduleType: SCHEDULE_TYPES.includes(e.scheduleType as ScheduleType)
|
||||
? (e.scheduleType as ScheduleType)
|
||||
: "SCHEDULED",
|
||||
cyclicOnWeeks:
|
||||
typeof e.cyclicOnWeeks === "number" ? e.cyclicOnWeeks : null,
|
||||
cyclicOffWeeks:
|
||||
typeof e.cyclicOffWeeks === "number" ? e.cyclicOffWeeks : null,
|
||||
doseWindows: normaliseDoseWindows(e.doseWindows),
|
||||
};
|
||||
// A legacy daily row carrying only `windowStart` surfaces it as the
|
||||
// single time-of-day so the minter mints its daily band — the same
|
||||
// normalisation every band-building consumer applies to live rows.
|
||||
if (
|
||||
base.timesOfDay.length === 0 &&
|
||||
base.rrule === null &&
|
||||
base.rollingIntervalDays === null &&
|
||||
base.scheduleType !== "PRN" &&
|
||||
options?.oneShot !== true
|
||||
) {
|
||||
return { ...base, timesOfDay: [base.windowStart] };
|
||||
}
|
||||
return base;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment an inclusive minting `range` into eras. Each revision interval
|
||||
* that intersects the range contributes one era with ITS archived
|
||||
* schedules; the remainder (from the newest `validUntil` onward) is the
|
||||
* live era. Eras are returned chronologically; an empty intersection is
|
||||
* dropped. With no revisions the result is a single live era covering the
|
||||
* whole range — the zero-revision path is byte-equivalent to not
|
||||
* segmenting at all.
|
||||
*/
|
||||
export function segmentRangeIntoEras(
|
||||
range: { from: Date; to: Date },
|
||||
revisions: ScheduleRevisionLike[],
|
||||
liveSchedules: CanonicalSchedule[],
|
||||
options?: { oneShot?: boolean },
|
||||
): ScheduleEra[] {
|
||||
const eras: ScheduleEra[] = [];
|
||||
const sorted = [...revisions].sort(
|
||||
(a, b) => a.validFrom.getTime() - b.validFrom.getTime(),
|
||||
);
|
||||
for (const revision of sorted) {
|
||||
const from = new Date(
|
||||
Math.max(revision.validFrom.getTime(), range.from.getTime()),
|
||||
);
|
||||
// Inclusive sub-range capped 1 ms short of `validUntil` — the anchor
|
||||
// boundary rule: a slot exactly at the boundary belongs to the era
|
||||
// that begins there, never to the one ending there.
|
||||
const to = new Date(
|
||||
Math.min(revision.validUntil.getTime() - 1, range.to.getTime()),
|
||||
);
|
||||
if (from.getTime() > to.getTime()) continue;
|
||||
eras.push({
|
||||
from,
|
||||
to,
|
||||
schedules: canonicalSchedulesFromRevision(revision, options),
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
const liveFrom =
|
||||
sorted.length > 0
|
||||
? new Date(
|
||||
Math.max(
|
||||
sorted[sorted.length - 1].validUntil.getTime(),
|
||||
range.from.getTime(),
|
||||
),
|
||||
)
|
||||
: range.from;
|
||||
if (liveFrom.getTime() <= range.to.getTime()) {
|
||||
eras.push({ from: liveFrom, to: range.to, schedules: liveSchedules, live: true });
|
||||
}
|
||||
return eras;
|
||||
}
|
||||
|
||||
/**
|
||||
* Era-aware drop-in for `buildBandsForSchedules`: segments the range into
|
||||
* eras and mints each era with the schedules that were live THEN,
|
||||
* concatenating the per-schedule groups. Archived-era groups carry the
|
||||
* synthetic `rev:<id>:<idx>` schedule ids. With no revisions this is a
|
||||
* plain pass-through to `buildBandsForSchedules` — every existing caller
|
||||
* keeps its exact behaviour until it threads revisions in.
|
||||
*/
|
||||
export function buildBandsForSchedulesWithEras(input: {
|
||||
medication: BandMinterMedication;
|
||||
schedules: CanonicalSchedule[];
|
||||
revisions: ScheduleRevisionLike[];
|
||||
ctx: RecurrenceContext;
|
||||
userTz: string;
|
||||
range: { from: Date; to: Date };
|
||||
now: Date;
|
||||
windowConfig?: DoseWindowConfig;
|
||||
intakeInstants?: Date[];
|
||||
}): ScheduleBandGroup[] {
|
||||
if (input.revisions.length === 0) {
|
||||
return buildBandsForSchedules(input);
|
||||
}
|
||||
const eras = segmentRangeIntoEras(input.range, input.revisions, input.schedules, {
|
||||
oneShot: input.medication.oneShot,
|
||||
});
|
||||
const groups: ScheduleBandGroup[] = [];
|
||||
for (const era of eras) {
|
||||
groups.push(
|
||||
...buildBandsForSchedules({
|
||||
medication: input.medication,
|
||||
schedules: era.schedules,
|
||||
ctx: input.ctx,
|
||||
userTz: input.userTz,
|
||||
range: { from: era.from, to: era.to },
|
||||
now: input.now,
|
||||
windowConfig: input.windowConfig,
|
||||
intakeInstants: input.intakeInstants,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Era-aware occurrence expansion for the count-shaped consumers
|
||||
* (`expectedSlotCountForDay` / `expectedSlotsBetween`): expands each era's
|
||||
* schedules over the era's sub-range via the supplied expander and returns
|
||||
* the chronological union. The expander signature matches the per-schedule
|
||||
* loop those callers already run, so the era split slots in without
|
||||
* duplicating their retro-rolling plumbing.
|
||||
*/
|
||||
export function occurrencesAcrossEras(
|
||||
range: { from: Date; to: Date },
|
||||
revisions: ScheduleRevisionLike[],
|
||||
liveSchedules: CanonicalSchedule[],
|
||||
expand: (schedule: CanonicalSchedule, from: Date, to: Date) => Occurrence[],
|
||||
options?: { oneShot?: boolean },
|
||||
): Occurrence[] {
|
||||
const eras = segmentRangeIntoEras(range, revisions, liveSchedules, options);
|
||||
const all: Occurrence[] = [];
|
||||
for (const era of eras) {
|
||||
for (const schedule of era.schedules) {
|
||||
all.push(...expand(schedule, era.from, era.to));
|
||||
}
|
||||
}
|
||||
return all.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Write-path helpers: snapshot + material-change compare
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The schedule-row projection the snapshot reads (a Prisma row drops in). */
|
||||
export interface ScheduleSnapshotRow {
|
||||
timesOfDay: string[];
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
daysOfWeek: string | null;
|
||||
rrule: string | null;
|
||||
rollingIntervalDays: number | null;
|
||||
scheduleType: ScheduleType;
|
||||
cyclicOnWeeks: number | null;
|
||||
cyclicOffWeeks: number | null;
|
||||
doseWindows: unknown;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
reminderGraceMinutes: number | null;
|
||||
}
|
||||
|
||||
/** Snapshot one schedule row into a revision payload entry. */
|
||||
export function toRevisionPayloadEntry(
|
||||
row: ScheduleSnapshotRow,
|
||||
): ScheduleRevisionEntry {
|
||||
return {
|
||||
timesOfDay: [...row.timesOfDay],
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
daysOfWeek: row.daysOfWeek,
|
||||
rrule: row.rrule,
|
||||
rollingIntervalDays: row.rollingIntervalDays,
|
||||
scheduleType: row.scheduleType,
|
||||
cyclicOnWeeks: row.cyclicOnWeeks,
|
||||
cyclicOffWeeks: row.cyclicOffWeeks,
|
||||
doseWindows: row.doseWindows ?? null,
|
||||
label: row.label,
|
||||
dose: row.dose,
|
||||
reminderGraceMinutes: row.reminderGraceMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalised cadence fingerprint of one snapshot entry. Sorted
|
||||
* `timesOfDay`, normalised `doseWindows` sorted by `timeOfDay`, and only
|
||||
* the fields that change WHICH slots/bands history mints — `label` and
|
||||
* `dose` are display-only and excluded, so renaming a schedule never
|
||||
* archives a phantom revision.
|
||||
*/
|
||||
function cadenceFingerprint(entry: ScheduleRevisionEntry): string {
|
||||
const windows = (normaliseDoseWindows(entry.doseWindows) ?? [])
|
||||
.map((w) => `${w.timeOfDay}>${w.start}-${w.end}`)
|
||||
.sort();
|
||||
return JSON.stringify({
|
||||
timesOfDay: [...entry.timesOfDay].sort(),
|
||||
windowStart: entry.windowStart,
|
||||
windowEnd: entry.windowEnd,
|
||||
daysOfWeek: entry.daysOfWeek,
|
||||
rrule: entry.rrule,
|
||||
rollingIntervalDays: entry.rollingIntervalDays,
|
||||
scheduleType: entry.scheduleType,
|
||||
cyclicOnWeeks: entry.cyclicOnWeeks,
|
||||
cyclicOffWeeks: entry.cyclicOffWeeks,
|
||||
doseWindows: windows,
|
||||
reminderGraceMinutes: entry.reminderGraceMinutes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the two schedule sets differ in any cadence-relevant field —
|
||||
* the gate for archiving a revision on a wholesale replace. Order- and
|
||||
* label-insensitive: a no-op edit (the editor echoing the same rows back,
|
||||
* possibly reordered or relabelled) must NOT mint a revision.
|
||||
*/
|
||||
export function schedulesMateriallyDiffer(
|
||||
before: ScheduleRevisionEntry[],
|
||||
after: ScheduleRevisionEntry[],
|
||||
): boolean {
|
||||
if (before.length !== after.length) return true;
|
||||
const a = before.map(cadenceFingerprint).sort();
|
||||
const b = after.map(cadenceFingerprint).sort();
|
||||
return a.some((fp, i) => fp !== b[i]);
|
||||
}
|
||||
@@ -333,6 +333,12 @@ const MEDICATION_SELECT = {
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
schedules: { select: SCHEDULE_SELECT },
|
||||
// v1.16.3 — archived schedule eras: a write/edit at a historical instant
|
||||
// attributes against the era that was live at that takenAt.
|
||||
scheduleRevisions: {
|
||||
orderBy: { validFrom: "asc" },
|
||||
select: { id: true, validFrom: true, validUntil: true, payload: true },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface ResolveSlotForWriteInput {
|
||||
|
||||
Reference in new issue
Block a user