mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(medications): build the on-time band from the persisted dose window
Thread the persisted `doseWindows` through the shared band minter so the on-time band for a dose time uses its explicit `[start, end]` range (minted DST-correctly on the slot's local day) instead of the symmetric ±1h default; the late tail stays cadence-derived. A `normaliseDoseWindows` helper coerces the stored JSON into a clean entry list, dropping anything malformed so the read/write paths never defend against an arbitrary shape. Every band consumer reads the same field: the dose-history endpoint, the compliance %, the cadence timeline (card last/next dose), and the write/edit slot attribution all resolve through one minter, so the history view and the rate can never disagree. The write/edit and health-score schedule selects now pull the column.
This commit is contained in:
1 parent
9bb68ade8a
commit
6526780f3b
12 files changed
+438
-19
No files matched your search
@@ -23,6 +23,7 @@ import {
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
@@ -40,6 +41,8 @@ type MedicationScheduleRow = {
|
||||
scheduleType: "SCHEDULED" | "PRN" | "CYCLIC";
|
||||
cyclicOnWeeks: number | null;
|
||||
cyclicOffWeeks: number | null;
|
||||
/** v1.15.18 — per-dose configurable on-time windows (persisted JSON). */
|
||||
doseWindows: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -63,6 +66,7 @@ function toCanonicalForBands(
|
||||
scheduleType: s.scheduleType,
|
||||
cyclicOnWeeks: s.cyclicOnWeeks,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
doseWindows: normaliseDoseWindows(s.doseWindows),
|
||||
};
|
||||
if (
|
||||
base.timesOfDay.length === 0 &&
|
||||
|
||||
@@ -2819,4 +2819,51 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => {
|
||||
expect(tally.taken).toBeGreaterThanOrEqual(3);
|
||||
expect(tally.rate).toBeGreaterThanOrEqual(90);
|
||||
});
|
||||
|
||||
// v1.15.18 W7 — the persisted per-dose window reaches the % through the
|
||||
// SAME minter the history view + write path use. An 11:29 take that the
|
||||
// default ±1h leaves ad-hoc (slot missed) is counted on-time once the
|
||||
// morning slot carries an explicit 07:00–12:00 window.
|
||||
it("the persisted doseWindows widens the on-time band feeding the %", () => {
|
||||
const events = [
|
||||
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false },
|
||||
{ scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false },
|
||||
];
|
||||
// DEFAULT (no doseWindows): 11:29 is past the 11:00 cutoff → ad-hoc,
|
||||
// morning slot missed.
|
||||
const dflt = tallyComplianceFromLedger(
|
||||
events,
|
||||
twiceDaily,
|
||||
ctxFor(),
|
||||
from,
|
||||
nowEvening,
|
||||
nowEvening,
|
||||
);
|
||||
expect(dflt.missed).toBe(1);
|
||||
expect(dflt.adHoc).toBe(1);
|
||||
|
||||
// WITH the persisted 07:00–12:00 window: 11:29 lands on-time, nothing
|
||||
// missed, nothing orphaned.
|
||||
const windowed: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "12:00" }],
|
||||
},
|
||||
];
|
||||
const wt = tallyComplianceFromLedger(
|
||||
events,
|
||||
windowed,
|
||||
ctxFor(),
|
||||
from,
|
||||
nowEvening,
|
||||
nowEvening,
|
||||
);
|
||||
expect(wt.missed).toBe(0);
|
||||
expect(wt.adHoc).toBe(0);
|
||||
expect(wt.takenOnTime).toBe(2);
|
||||
expect(wt.rate).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ScheduleLike,
|
||||
} from "@/lib/medications/scheduling/cadence";
|
||||
import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type HistoryIntake,
|
||||
@@ -371,6 +372,15 @@ export interface ComplianceSchedule {
|
||||
scheduleType?: ScheduleType | null;
|
||||
cyclicOnWeeks?: number | null;
|
||||
cyclicOffWeeks?: number | null;
|
||||
/**
|
||||
* v1.15.18 — per-dose configurable on-time windows. Threaded onto the
|
||||
* canonical schedule so `tallyComplianceFromLedger` builds the on-time band
|
||||
* from the explicit `[start, end]` range — keeping the % exactly aligned with
|
||||
* the dose-history view and the write/edit attribution (all consume the same
|
||||
* minter). Accepts the raw persisted JSON (a full Prisma row drops straight
|
||||
* in); `toCanonicalSchedule` normalises it via `normaliseDoseWindows`.
|
||||
*/
|
||||
doseWindows?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,6 +509,7 @@ function toCanonicalSchedule(
|
||||
scheduleType: s.scheduleType ?? "SCHEDULED",
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
doseWindows: normaliseDoseWindows(s.doseWindows),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -332,6 +332,9 @@ export async function computeUserHealthScoreFastPath(
|
||||
scheduleType: true,
|
||||
cyclicOnWeeks: true,
|
||||
cyclicOffWeeks: true,
|
||||
// v1.15.18 — per-dose window so the score's compliance honours the
|
||||
// same on-time band the detail % + history use.
|
||||
doseWindows: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "../recurrence";
|
||||
import { attributeIntakeToSlot } from "../attribution";
|
||||
import { localHmAsUtc } from "@/lib/timezone";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
@@ -468,3 +469,102 @@ describe("multi-schedule — bands built PER SCHEDULE (no cross-clip)", () => {
|
||||
expect(span).toBe(2 * 60 * MIN); // ±60min
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// 9. Per-dose configurable window (Marc's "07:00–09:00" lever, v1.15.18 W7)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("explicit per-dose window from schedule.doseWindows", () => {
|
||||
const m = med();
|
||||
const day = new Date("2026-06-08T12:00:00Z");
|
||||
// Morning dose carries an explicit 07:00–12:00 window; the evening dose
|
||||
// none, so it keeps the symmetric ±60min default.
|
||||
const sched = schedule({
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "12:00" }],
|
||||
});
|
||||
const out = buildBandsForMedication({
|
||||
medication: m,
|
||||
schedule: sched,
|
||||
ctx: ctxFor(m),
|
||||
userTz: TZ,
|
||||
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
|
||||
now: at(day, 23, 59),
|
||||
});
|
||||
const morning = out.bands.find((b) => b.timeOfDay === "07:00")!;
|
||||
const evening = out.bands.find((b) => b.timeOfDay === "19:00")!;
|
||||
|
||||
it("builds the on-time band from the explicit [start,end] range", () => {
|
||||
expect(morning.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime());
|
||||
expect(morning.onTimeEnd.getTime()).toBe(at(day, 12, 0).getTime());
|
||||
});
|
||||
|
||||
it("an 11:29 take lands inside the widened on-time window", () => {
|
||||
expect(attributeIntakeToSlot(at(day, 11, 29), out.bands)).toMatchObject({
|
||||
band: { timeOfDay: "07:00" },
|
||||
status: "on_time",
|
||||
});
|
||||
});
|
||||
|
||||
it("a dose with no entry keeps the symmetric ±60min default", () => {
|
||||
expect(evening.onTimeStart.getTime()).toBe(at(day, 19, 0).getTime() - 60 * MIN);
|
||||
expect(evening.onTimeEnd.getTime()).toBe(at(day, 19, 0).getTime() + 60 * MIN);
|
||||
});
|
||||
|
||||
it("under the DEFAULT (no doseWindows) the same 11:29 take is ad-hoc", () => {
|
||||
const dflt = buildBandsForMedication({
|
||||
medication: m,
|
||||
schedule: schedule({ timesOfDay: ["07:00", "19:00"] }),
|
||||
ctx: ctxFor(m),
|
||||
userTz: TZ,
|
||||
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
|
||||
now: at(day, 23, 59),
|
||||
});
|
||||
// 11:29 is > 07:00 + on-time(60) + tail(180) = 11:00 cutoff → no band.
|
||||
expect(attributeIntakeToSlot(at(day, 11, 29), dflt.bands)).toBeNull();
|
||||
});
|
||||
|
||||
it("a malformed doseWindows shape falls back to the default derivation", () => {
|
||||
const out2 = buildBandsForMedication({
|
||||
medication: m,
|
||||
// start > end is dropped by the normaliser; an unrelated timeOfDay is
|
||||
// ignored. Neither widens the 07:00 band.
|
||||
schedule: schedule({
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
doseWindows: [
|
||||
{ timeOfDay: "07:00", start: "12:00", end: "07:00" },
|
||||
] as never,
|
||||
}),
|
||||
ctx: ctxFor(m),
|
||||
userTz: TZ,
|
||||
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
|
||||
now: at(day, 23, 59),
|
||||
});
|
||||
const morn = out2.bands.find((b) => b.timeOfDay === "07:00")!;
|
||||
expect(morn.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime() - 60 * MIN);
|
||||
expect(morn.onTimeEnd.getTime()).toBe(at(day, 7, 0).getTime() + 60 * MIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit per-dose window is DST-correct (winter)", () => {
|
||||
const m = med();
|
||||
const winterDay = new Date("2026-01-15T12:00:00Z");
|
||||
const sched = schedule({
|
||||
timesOfDay: ["07:00"],
|
||||
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }],
|
||||
});
|
||||
const out = buildBandsForMedication({
|
||||
medication: m,
|
||||
schedule: sched,
|
||||
ctx: ctxFor(m),
|
||||
userTz: TZ,
|
||||
range: { from: at(winterDay, 0, 0), to: at(winterDay, 23, 59) },
|
||||
now: at(winterDay, 23, 59),
|
||||
});
|
||||
|
||||
it("mints the [start,end] bounds on the slot's local winter day (CET = +1)", () => {
|
||||
const band = out.bands[0];
|
||||
expect(band.onTimeStart.toISOString()).toBe("2026-01-15T06:00:00.000Z");
|
||||
expect(band.onTimeEnd.toISOString()).toBe("2026-01-15T08:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* v1.15.18 — `normaliseDoseWindows` coerces the persisted `dose_windows` JSON
|
||||
* into a clean `DoseWindowEntry[]`, dropping anything malformed so the band
|
||||
* paths never have to defend against an arbitrary JSON shape.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCanonicalSchedule,
|
||||
normaliseDoseWindows,
|
||||
type WorkerScheduleRow,
|
||||
} from "../worker-helpers";
|
||||
|
||||
describe("normaliseDoseWindows", () => {
|
||||
it("returns null for null / non-array input", () => {
|
||||
expect(normaliseDoseWindows(null)).toBeNull();
|
||||
expect(normaliseDoseWindows(undefined)).toBeNull();
|
||||
expect(normaliseDoseWindows("nope")).toBeNull();
|
||||
expect(normaliseDoseWindows({ timeOfDay: "07:00" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a well-formed entry", () => {
|
||||
expect(
|
||||
normaliseDoseWindows([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]),
|
||||
).toEqual([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]);
|
||||
});
|
||||
|
||||
it("drops entries with start > end, bad HH:mm, or missing keys", () => {
|
||||
expect(
|
||||
normaliseDoseWindows([
|
||||
{ timeOfDay: "07:00", start: "12:00", end: "07:00" }, // start > end
|
||||
{ timeOfDay: "7:00", start: "07:00", end: "09:00" }, // bad HH:mm
|
||||
{ timeOfDay: "19:00", start: "19:00" }, // missing end
|
||||
{ timeOfDay: "08:00", start: "25:00", end: "26:00" }, // out of range
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the good entries and drops the bad ones in a mixed array", () => {
|
||||
expect(
|
||||
normaliseDoseWindows([
|
||||
{ timeOfDay: "07:00", start: "07:00", end: "09:00" }, // good
|
||||
{ timeOfDay: "19:00", start: "20:00", end: "19:00" }, // start > end
|
||||
]),
|
||||
).toEqual([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]);
|
||||
});
|
||||
|
||||
it("threads through buildCanonicalSchedule onto the canonical schedule", () => {
|
||||
const row: WorkerScheduleRow = {
|
||||
id: "s1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }],
|
||||
};
|
||||
expect(buildCanonicalSchedule(row).doseWindows).toEqual([
|
||||
{ timeOfDay: "07:00", start: "07:00", end: "09:00" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a row with no doseWindows yields null on the canonical schedule", () => {
|
||||
const row: WorkerScheduleRow = {
|
||||
id: "s1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
};
|
||||
expect(buildCanonicalSchedule(row).doseWindows).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
expandRollingRetrospective,
|
||||
occurrencesBetween,
|
||||
type CanonicalSchedule,
|
||||
type DoseWindowEntry,
|
||||
type Occurrence,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
@@ -172,7 +173,7 @@ export function buildBandsForMedication(
|
||||
// here (intakes drive the anchors), so the interval is the right signal.
|
||||
const family: "daily" | "weekly" =
|
||||
schedule.rollingIntervalDays >= 2 ? "weekly" : "daily";
|
||||
const bands = mintBands(occ, family, input.windowConfig, userTz);
|
||||
const bands = mintBands(occ, family, input.windowConfig, userTz, schedule);
|
||||
return { bands, hasExpectedSlots: true, family };
|
||||
}
|
||||
|
||||
@@ -182,7 +183,7 @@ export function buildBandsForMedication(
|
||||
// field-shape `doseCadenceFamily` heuristic.
|
||||
const occurrences = occurrencesBetween(schedule, range.from, range.to, ctx);
|
||||
const family = realisedFamily(schedule, ctx, range);
|
||||
const bands = mintBands(occurrences, family, input.windowConfig, userTz);
|
||||
const bands = mintBands(occurrences, family, input.windowConfig, userTz, schedule);
|
||||
return { bands, hasExpectedSlots: true, family };
|
||||
}
|
||||
|
||||
@@ -231,30 +232,106 @@ function mintBands(
|
||||
family: "daily" | "weekly",
|
||||
windowConfig: DoseWindowConfig | undefined,
|
||||
userTz: string,
|
||||
schedule: CanonicalSchedule,
|
||||
): SlotBand[] {
|
||||
const w = resolveWindows(windowConfig);
|
||||
// The persisted per-dose windows (Marc's "07:00–09:00" lever). When a slot's
|
||||
// `timeOfDay` matches an entry, the on-time band is the explicit `[start,
|
||||
// end]` range; otherwise the default symmetric ±width applies. The late tail
|
||||
// stays cadence-derived for every slot.
|
||||
const explicit = indexDoseWindows(schedule.doseWindows ?? null);
|
||||
const inputs: SlotWindowInput[] = occurrences.map((occ) =>
|
||||
family === "weekly"
|
||||
? weeklyWindow(occ, w, userTz)
|
||||
: dailyWindow(occ, w),
|
||||
? weeklyWindow(occ, w, userTz, explicit.get(occ.timeOfDay) ?? null)
|
||||
: dailyWindow(occ, w, userTz, explicit.get(occ.timeOfDay) ?? null),
|
||||
);
|
||||
return buildSlotBands(inputs);
|
||||
}
|
||||
|
||||
/** Minute-scale band around a slot instant (daily / intraday cadences). */
|
||||
/**
|
||||
* Index the schedule's `doseWindows` by `timeOfDay` for O(1) per-slot lookup.
|
||||
* A duplicate `timeOfDay` keeps the first entry (the write path Zod-validates
|
||||
* uniqueness, so this is purely defensive).
|
||||
*/
|
||||
function indexDoseWindows(
|
||||
windows: DoseWindowEntry[] | null,
|
||||
): Map<string, DoseWindowEntry> {
|
||||
const map = new Map<string, DoseWindowEntry>();
|
||||
if (!windows) return map;
|
||||
for (const w of windows) {
|
||||
// Defence-in-depth: only index well-formed `start <= end` HH:mm entries.
|
||||
// `buildCanonicalSchedule` already normalises the persisted JSON, but a
|
||||
// directly-constructed `CanonicalSchedule` (tests, compliance helpers)
|
||||
// must never widen a band from a malformed range.
|
||||
if (!isUsableDoseWindow(w)) continue;
|
||||
if (!map.has(w.timeOfDay)) map.set(w.timeOfDay, w);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** True when a dose window is well-formed HH:mm with `start <= end`. */
|
||||
function isUsableDoseWindow(w: DoseWindowEntry): boolean {
|
||||
const start = localHourMinute(w.start);
|
||||
const end = localHourMinute(w.end);
|
||||
const startMin = start.hour * 60 + start.minute;
|
||||
const endMin = end.hour * 60 + end.minute;
|
||||
return (
|
||||
HHMM_BAND_RE.test(w.timeOfDay) &&
|
||||
HHMM_BAND_RE.test(w.start) &&
|
||||
HHMM_BAND_RE.test(w.end) &&
|
||||
startMin <= endMin
|
||||
);
|
||||
}
|
||||
|
||||
const HHMM_BAND_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
/**
|
||||
* Minute-scale band around a slot instant (daily / intraday cadences). When an
|
||||
* explicit per-dose window is configured for this `timeOfDay`, the on-time band
|
||||
* is the literal `[start, end]` range minted on the slot's local day (DST-correct
|
||||
* via `localHmAsUtc`); otherwise the symmetric ±`dailyOnTimeMinutes` default.
|
||||
*/
|
||||
function dailyWindow(
|
||||
occ: Occurrence,
|
||||
w: ResolvedWindows,
|
||||
userTz: string,
|
||||
explicit: DoseWindowEntry | null,
|
||||
): SlotWindowInput {
|
||||
const onTime = explicit
|
||||
? explicitOnTimeBounds(occ.at, explicit, userTz)
|
||||
: {
|
||||
onTimeStart: new Date(
|
||||
occ.at.getTime() - w.dailyOnTimeMinutes * MINUTE_MS,
|
||||
),
|
||||
onTimeEnd: new Date(occ.at.getTime() + w.dailyOnTimeMinutes * MINUTE_MS),
|
||||
};
|
||||
return {
|
||||
at: occ.at,
|
||||
timeOfDay: occ.timeOfDay,
|
||||
onTimeStart: new Date(occ.at.getTime() - w.dailyOnTimeMinutes * MINUTE_MS),
|
||||
onTimeEnd: new Date(occ.at.getTime() + w.dailyOnTimeMinutes * MINUTE_MS),
|
||||
onTimeStart: onTime.onTimeStart,
|
||||
onTimeEnd: onTime.onTimeEnd,
|
||||
lateGraceMs: w.dailyOverdueMinutes * MINUTE_MS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint an explicit `[start, end]` on-time range on the slot's local calendar
|
||||
* day. `localHmAsUtc` keys off the local day of its first argument (`occ.at`),
|
||||
* so the bounds land on the same day as the slot, DST-correct.
|
||||
*/
|
||||
function explicitOnTimeBounds(
|
||||
occAt: Date,
|
||||
window: DoseWindowEntry,
|
||||
userTz: string,
|
||||
): { onTimeStart: Date; onTimeEnd: Date } {
|
||||
const start = localHourMinute(window.start);
|
||||
const end = localHourMinute(window.end);
|
||||
return {
|
||||
onTimeStart: localHmAsUtc(occAt, userTz, start.hour, start.minute),
|
||||
onTimeEnd: localHmAsUtc(occAt, userTz, end.hour, end.minute),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-scale band around a slot instant (weekly / rolling / sparse cadences).
|
||||
* The ±N-day bounds are minted via `localHmAsUtc` on the calendar days N days
|
||||
@@ -265,20 +342,39 @@ function weeklyWindow(
|
||||
occ: Occurrence,
|
||||
w: ResolvedWindows,
|
||||
userTz: string,
|
||||
explicit: DoseWindowEntry | null,
|
||||
): SlotWindowInput {
|
||||
const { hour, minute } = localHourMinute(occ.timeOfDay);
|
||||
const onTimeStart = localHmAsUtc(
|
||||
new Date(occ.at.getTime() - w.weeklyOnTimeDays * DAY_MS),
|
||||
userTz,
|
||||
hour,
|
||||
minute,
|
||||
);
|
||||
const onTimeEnd = localHmAsUtc(
|
||||
new Date(occ.at.getTime() + w.weeklyOnTimeDays * DAY_MS),
|
||||
userTz,
|
||||
hour,
|
||||
minute,
|
||||
);
|
||||
// An explicit per-dose window pins the on-time band to the literal `[start,
|
||||
// end]` range on the slot's own local day (a within-day window the user set);
|
||||
// otherwise the default day-scale ±`weeklyOnTimeDays` envelope. Either way the
|
||||
// late tail below stays day-scale + DST-correct.
|
||||
const onTimeStart = explicit
|
||||
? localHmAsUtc(
|
||||
occ.at,
|
||||
userTz,
|
||||
localHourMinute(explicit.start).hour,
|
||||
localHourMinute(explicit.start).minute,
|
||||
)
|
||||
: localHmAsUtc(
|
||||
new Date(occ.at.getTime() - w.weeklyOnTimeDays * DAY_MS),
|
||||
userTz,
|
||||
hour,
|
||||
minute,
|
||||
);
|
||||
const onTimeEnd = explicit
|
||||
? localHmAsUtc(
|
||||
occ.at,
|
||||
userTz,
|
||||
localHourMinute(explicit.end).hour,
|
||||
localHourMinute(explicit.end).minute,
|
||||
)
|
||||
: localHmAsUtc(
|
||||
new Date(occ.at.getTime() + w.weeklyOnTimeDays * DAY_MS),
|
||||
userTz,
|
||||
hour,
|
||||
minute,
|
||||
);
|
||||
// The late tail is also day-scale: the instant N overdue-days past the
|
||||
// on-time end, minted on the calendar day for DST-correctness, then expressed
|
||||
// as a millisecond grace `buildSlotBands` consumes.
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
expandRollingRetrospective,
|
||||
occurrencesBetween,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
/**
|
||||
@@ -96,6 +97,14 @@ export interface ScheduleLike {
|
||||
scheduleType?: ScheduleType | null;
|
||||
cyclicOnWeeks?: number | null;
|
||||
cyclicOffWeeks?: number | null;
|
||||
/**
|
||||
* v1.15.18 — per-dose configurable on-time windows. Carried onto the
|
||||
* canonical schedule so the cadence-timeline bands (the card last/next dose)
|
||||
* honour the same explicit window the % and the history view use. Accepts the
|
||||
* raw persisted JSON (a full Prisma row's `Json?` column drops straight in);
|
||||
* `toCanonical` normalises it via `normaliseDoseWindows`.
|
||||
*/
|
||||
doseWindows?: unknown;
|
||||
/** Stable id for the engine occurrence; defaults to a synthetic value. */
|
||||
id?: string;
|
||||
}
|
||||
@@ -135,6 +144,7 @@ function toCanonical(
|
||||
scheduleType: schedule.scheduleType ?? "SCHEDULED",
|
||||
cyclicOnWeeks: schedule.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: schedule.cyclicOffWeeks ?? null,
|
||||
doseWindows: normaliseDoseWindows(schedule.doseWindows),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "./recurrence";
|
||||
import { normaliseDoseWindows } from "./worker-helpers";
|
||||
|
||||
export interface ComplianceChips {
|
||||
/** 0-100, taken / (taken + missed). Skipped doses are excluded from
|
||||
@@ -211,6 +212,7 @@ function ledgerChipCounts(
|
||||
scheduleType: s.scheduleType ?? "SCHEDULED",
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
doseWindows: normaliseDoseWindows(s.doseWindows),
|
||||
};
|
||||
// Surface windowStart as the single time-of-day for a legacy daily row
|
||||
// so the band minter's cadence gate mints its daily band (see the
|
||||
|
||||
@@ -81,6 +81,25 @@ export interface CanonicalSchedule {
|
||||
cyclicOnWeeks: number | null;
|
||||
/** v1.7.0 — cyclic "off" weeks. Only read when `scheduleType === "CYCLIC"`. */
|
||||
cyclicOffWeeks: number | null;
|
||||
/**
|
||||
* v1.15.18 — per-dose configurable on-time intake windows. The recurrence
|
||||
* engine itself never reads these (slot timing is unchanged); they ride on
|
||||
* the canonical schedule purely so the band minter can build a slot's
|
||||
* on-time band from an explicit `[start, end]` range for the matching
|
||||
* `timeOfDay`. NULL / empty = every slot uses the default ±1h derivation.
|
||||
*/
|
||||
doseWindows?: DoseWindowEntry[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose
|
||||
* the window applies to (matching a `timesOfDay` entry); `start`/`end` are the
|
||||
* HH:mm on-time bounds in the user's wall clock (`start <= end`, same local day).
|
||||
*/
|
||||
export interface DoseWindowEntry {
|
||||
timeOfDay: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface RecurrenceContext {
|
||||
|
||||
@@ -299,6 +299,9 @@ const SCHEDULE_SELECT = {
|
||||
scheduleType: true,
|
||||
cyclicOnWeeks: true,
|
||||
cyclicOffWeeks: true,
|
||||
// v1.15.18 — the persisted per-dose window the band attribution honours so
|
||||
// the write/edit slot binding uses the SAME on-time window as the % + history.
|
||||
doseWindows: true,
|
||||
} as const;
|
||||
|
||||
const MEDICATION_SELECT = {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import {
|
||||
type CanonicalSchedule,
|
||||
type DoseWindowEntry,
|
||||
type RecurrenceContext,
|
||||
type ScheduleType,
|
||||
occurrencesBetween,
|
||||
@@ -48,6 +49,13 @@ export interface WorkerScheduleRow {
|
||||
scheduleType?: ScheduleType | null;
|
||||
cyclicOnWeeks?: number | null;
|
||||
cyclicOffWeeks?: number | null;
|
||||
/**
|
||||
* v1.15.18 — per-dose on-time windows. The Prisma column is `Json?`, so a
|
||||
* selected row surfaces it as `Prisma.JsonValue` (or `null`). The adapter
|
||||
* normalises it to a `DoseWindowEntry[]` (dropping malformed entries) so the
|
||||
* band minter never has to defend against an arbitrary JSON shape.
|
||||
*/
|
||||
doseWindows?: unknown;
|
||||
}
|
||||
|
||||
/** Minimal `Medication` projection used by the worker. */
|
||||
@@ -78,9 +86,47 @@ export function buildCanonicalSchedule(
|
||||
scheduleType: schedule.scheduleType ?? "SCHEDULED",
|
||||
cyclicOnWeeks: schedule.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: schedule.cyclicOffWeeks ?? null,
|
||||
doseWindows: normaliseDoseWindows(schedule.doseWindows),
|
||||
};
|
||||
}
|
||||
|
||||
const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
/**
|
||||
* v1.15.18 — coerce the persisted `dose_windows` JSON into a clean
|
||||
* `DoseWindowEntry[]`. Drops anything that isn't an `{ timeOfDay, start, end }`
|
||||
* triple of well-formed HH:mm strings with `start <= end` — the column is
|
||||
* Zod-validated on write, but a hand-edited or legacy row must never crash the
|
||||
* read/write band paths. Returns `null` (the default-derivation signal) when
|
||||
* nothing usable survives.
|
||||
*/
|
||||
export function normaliseDoseWindows(raw: unknown): DoseWindowEntry[] | null {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const out: DoseWindowEntry[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
const { timeOfDay, start, end } = item as Record<string, unknown>;
|
||||
if (
|
||||
typeof timeOfDay !== "string" ||
|
||||
typeof start !== "string" ||
|
||||
typeof end !== "string" ||
|
||||
!HHMM_RE.test(timeOfDay) ||
|
||||
!HHMM_RE.test(start) ||
|
||||
!HHMM_RE.test(end) ||
|
||||
hhmmToMinutes(start) > hhmmToMinutes(end)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
out.push({ timeOfDay, start, end });
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function hhmmToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(":");
|
||||
return Number(h) * 60 + Number(m);
|
||||
}
|
||||
|
||||
/** Build the canonical engine context from worker-loop state. */
|
||||
export function buildRecurrenceContext(input: {
|
||||
medication: WorkerMedicationRow;
|
||||
|
||||
Reference in new issue
Block a user