import type { CheckInLimits, WeeklyHours, WindowRules } from "../api/types";
import { clock, dayLabel } from "./format";
const MIN = 60_000;
const DAY = 86_400_000;
type Interval = { start: number; end: number };
export type Window = { start: Date; end: Date };
/** The UTC offset of an IANA time zone at an instant, in minutes (Lagos: 60). */
export function zoneOffset(timeZone: string, at = new Date()): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(at);
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? 0);
const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
return Math.round((asUtc - Math.floor(at.getTime() / 1000) * 1000) / MIN);
}
const minutes = (hhmm: string) => {
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + (m || 0);
};
/** The instant of local midnight on the local date that contains `at`. */
const localMidnight = (at: number, offset: number) =>
Math.floor((at + offset * MIN) / DAY) * DAY - offset * MIN;
const localWeekday = (midnight: number, offset: number) => new Date(midnight + offset * MIN).getUTCDay();
/** Each local day's span between two clock times; a span past midnight wraps (22:00 to 07:00). */
function daily(
start: string,
end: string,
offset: number,
from: number,
to: number,
weekday: number | null = null,
): Interval[] {
const out: Interval[] = [];
for (let m = localMidnight(from, offset) - DAY; m < to; m += DAY) {
if (weekday !== null && localWeekday(m, offset) !== weekday) continue;
const s = m + minutes(start) * MIN;
let e = m + minutes(end) * MIN;
if (e <= s) e += DAY;
out.push({ start: s, end: e });
}
return out;
}
const weekly = (hours: WeeklyHours, offset: number, from: number, to: number) =>
hours.flatMap((h) => daily(h.open, h.close, offset, from, to, h.day));
function merge(list: Interval[]): Interval[] {
const sorted = [...list].sort((a, b) => a.start - b.start);
const out: Interval[] = [];
for (const i of sorted) {
const last = out[out.length - 1];
if (last && i.start <= last.end) last.end = Math.max(last.end, i.end);
else out.push({ ...i });
}
return out;
}
function intersect(a: Interval[], b: Interval[]): Interval[] {
const out: Interval[] = [];
for (const x of merge(a))
for (const y of merge(b)) {
const start = Math.max(x.start, y.start);
const end = Math.min(x.end, y.end);
if (end > start) out.push({ start, end });
}
return merge(out);
}
function subtract(a: Interval[], b: Interval[]): Interval[] {
let rest = merge(a);
for (const cut of merge(b))
rest = rest.flatMap((i) => {
if (cut.end <= i.start || cut.start >= i.end) return [i];
const parts: Interval[] = [];
if (cut.start > i.start) parts.push({ start: i.start, end: cut.start });
if (cut.end < i.end) parts.push({ start: cut.end, end: i.end });
return parts;
});
return rest;
}
/**
* YCI-5: when Yasmar may ring, today or tomorrow in the patient's time. Inside the
* clinic's hours and the alert-watch hours, between 08:00 and 20:00 for the patient,
* outside their quiet hours and blocked times, and not before now.
* Offsets are minutes from UTC; the caller resolves them from the time zones.
*/
export function allowedWindows(
rules: Pick<WindowRules, "clinicHours" | "alertWatchHours" | "dayStart" | "dayEnd" | "quietHours" | "blockedTimes">,
now: Date,
offsets: { patient: number; clinic: number },
minimumMinutes = 15,
): Window[] {
const from = now.getTime();
const to = localMidnight(from, offsets.patient) + 2 * DAY;
const open = intersect(
intersect(weekly(rules.clinicHours, offsets.clinic, from, to), weekly(rules.alertWatchHours, offsets.clinic, from, to)),
daily(rules.dayStart, rules.dayEnd, offsets.patient, from, to),
);
// Equal start and end means no quiet hours at all.
const quiet = rules.quietHours.start === rules.quietHours.end ? [] : daily(rules.quietHours.start, rules.quietHours.end, offsets.patient, from, to);
const closed = [
...quiet,
...rules.blockedTimes.flatMap((b) => daily(b.start, b.end, offsets.patient, from, to, b.day)),
];
return intersect(subtract(open, closed), [{ start: from, end: to }])
.filter((i) => i.end - i.start >= minimumMinutes * MIN)
.map((i) => ({ start: new Date(i.start), end: new Date(i.end) }));
}
/** Yasmar may ring now only if now is inside an allowed window. */
export const canStartNow = (windows: Window[], now: Date) =>
windows.some((w) => w.start.getTime() <= now.getTime() && now.getTime() < w.end.getTime());
/** Split windows into slots of up to `size` minutes, starting on the hour, so the clinician picks one. */
export function slots(windows: Window[], size = 120): Window[] {
const out: Window[] = [];
const HOUR = 60 * MIN;
for (const w of windows) {
let start = Math.ceil(w.start.getTime() / HOUR) * HOUR;
while (w.end.getTime() - start >= 15 * MIN) {
const end = Math.min(start + size * MIN, w.end.getTime());
out.push({ start: new Date(start), end: new Date(end) });
start = end;
}
}
return out;
}
/** "today 16:00 to 18:00", in the patient's clock. */
export function windowLabel(w: Window, patientOffset: number, now = new Date()): string {
const local = (d: Date) => new Date(d.getTime() + (patientOffset + d.getTimezoneOffset()) * MIN).toISOString();
const day = dayLabel(local(w.start), new Date(local(now)));
const dayText = day === "Today" || day === "Tomorrow" ? day.toLowerCase() : day;
return `${dayText} ${clock(local(w.start))} to ${clock(local(w.end))}`;
}
/** Hours until the request expires: 24 by default, up to the maximum (YCI-5). */
export const clampExpiry = (hours: number, max = 72) => Math.min(max, Math.max(1, Math.round(hours) || 24));
/** YCI-6: limits per patient across all their clinicians, and when the next is possible. */
export function limitState(limits: CheckInLimits, first: string, now = new Date()) {
const next = limits.nextPossibleAt
? ` The next one is possible ${dayLabel(limits.nextPossibleAt, now).toLowerCase()} at ${clock(limits.nextPossibleAt)}.`
: "";
if (limits.usedToday >= limits.maxPerDay)
return {
blocked: true,
message: `${first} has had a check-in today. Patients get at most ${limits.maxPerDay} a day and ${limits.maxPerWeek} a week, across all their clinicians.${next}`,
};
if (limits.usedThisWeek >= limits.maxPerWeek)
return {
blocked: true,
message: `${first} has had ${limits.usedThisWeek} check-ins this week, the most for one week across all their clinicians.${next}`,
};
return {
blocked: false,
message: `${limits.usedThisWeek} of ${limits.maxPerWeek} check-ins used this week, across all of ${first}'s clinicians.`,
};
}
/** The end of today's clinic hours, in the clinic's zone, or null if closed today. */
export function clinicCloseToday(hours: WeeklyHours, now: Date, clinicOffset: number): Date | null {
const midnight = localMidnight(now.getTime(), clinicOffset);
const today = weekly(hours, clinicOffset, now.getTime(), now.getTime() + 1).filter(
(i) => i.start >= midnight && i.start < midnight + DAY,
);
return today.length ? new Date(Math.max(...today.map((i) => i.end))) : null;
}