marmar-context / web / context / src / logic / windows.ts
windows.ts
Raw
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;
}