← back to NationalPaperHangers

lib/slots.js

198 lines

// Compute available booking slots for an installer over a date range.
//
// Inputs:
//   installerId
//   startDate, endDate (ISO strings or Date objects, inclusive)
//   slotMinutes (default 60)
//   bufferMinutes (default 15) — gap kept between bookings
//
// Algorithm:
//   1. Pull recurring weekly availability for the installer.
//   2. Pull existing confirmed/pending bookings in the range.
//   3. Pull time-off blocks in the range.
//   4. For each calendar day in [start, end]:
//        - Find availability windows for that day_of_week.
//        - Subtract any time_off intersecting that day.
//        - Subtract any booking that intersects.
//        - Slice the remaining gaps into slotMinutes-sized openings.
//   5. Return list of {start, end} ISO strings.

const { DateTime, Interval } = require('luxon');
const db = require('./db');

const DEFAULT_TZ = 'America/Los_Angeles';

async function getAvailability(installerId) {
  return db.many(
    `SELECT day_of_week, start_time::text AS start_time, end_time::text AS end_time, timezone
       FROM installer_availability
      WHERE installer_id = $1 AND active = true
      ORDER BY day_of_week, start_time`,
    [installerId]
  );
}

async function getTimeOff(installerId, fromIso, toIso) {
  return db.many(
    `SELECT start_at, end_at, all_day
       FROM installer_time_off
      WHERE installer_id = $1
        AND end_at >= $2
        AND start_at <= $3`,
    [installerId, fromIso, toIso]
  );
}

async function getBookings(installerId, fromIso, toIso) {
  return db.many(
    `SELECT scheduled_start, scheduled_end
       FROM bookings
      WHERE installer_id = $1
        AND status IN ('pending','confirmed')
        AND scheduled_end >= $2
        AND scheduled_start <= $3`,
    [installerId, fromIso, toIso]
  );
}

function subtractIntervals(base, blockers) {
  // base: Interval[]; blockers: Interval[]; returns base minus union(blockers)
  let result = base.slice();
  for (const b of blockers) {
    const next = [];
    for (const r of result) {
      if (!r.overlaps(b)) { next.push(r); continue; }
      const before = Interval.fromDateTimes(r.start, b.start);
      const after  = Interval.fromDateTimes(b.end,   r.end);
      if (before.isValid && before.length('minutes') > 0) next.push(before);
      if (after.isValid  && after.length('minutes')  > 0) next.push(after);
    }
    result = next;
  }
  return result;
}

function sliceIntoSlots(intervals, slotMinutes, bufferMinutes) {
  const out = [];
  for (const iv of intervals) {
    let cursor = iv.start;
    const span = slotMinutes;
    while (cursor.plus({ minutes: span }) <= iv.end) {
      const end = cursor.plus({ minutes: span });
      out.push({ start: cursor.toISO(), end: end.toISO() });
      cursor = end.plus({ minutes: bufferMinutes });
    }
  }
  return out;
}

async function availableSlots(installerId, opts = {}) {
  const slotMinutes   = opts.slotMinutes   || 60;
  const bufferMinutes = opts.bufferMinutes || 15;
  const tz            = opts.timezone      || DEFAULT_TZ;

  const start = DateTime.fromISO(opts.startDate, { zone: tz }).startOf('day');
  const end   = DateTime.fromISO(opts.endDate,   { zone: tz }).endOf('day');
  if (!start.isValid || !end.isValid || end <= start) return [];

  const fromIso = start.toUTC().toISO();
  const toIso   = end.toUTC().toISO();

  const [avail, timeOff, bookings] = await Promise.all([
    getAvailability(installerId),
    getTimeOff(installerId, fromIso, toIso),
    getBookings(installerId, fromIso, toIso)
  ]);

  if (avail.length === 0) return [];

  // Group availability by day-of-week
  const byDow = {};
  for (const a of avail) {
    if (!byDow[a.day_of_week]) byDow[a.day_of_week] = [];
    byDow[a.day_of_week].push(a);
  }

  const blockerIntervals = [
    ...timeOff.map(t => Interval.fromDateTimes(
      DateTime.fromJSDate(t.start_at).setZone(tz),
      DateTime.fromJSDate(t.end_at).setZone(tz)
    )),
    ...bookings.map(b => Interval.fromDateTimes(
      DateTime.fromJSDate(b.scheduled_start).setZone(tz),
      DateTime.fromJSDate(b.scheduled_end).setZone(tz)
    ))
  ].filter(i => i.isValid);

  const allSlots = [];
  let cursor = start;
  while (cursor < end) {
    // Luxon weekday: 1=Mon..7=Sun. We stored 0=Sun..6=Sat.
    const dow = cursor.weekday === 7 ? 0 : cursor.weekday;
    const todayWindows = byDow[dow] || [];
    const dayIntervals = [];
    for (const w of todayWindows) {
      const [sh, sm] = w.start_time.split(':').map(Number);
      const [eh, em] = w.end_time.split(':').map(Number);
      const winStart = cursor.set({ hour: sh, minute: sm, second: 0, millisecond: 0 });
      const winEnd   = cursor.set({ hour: eh, minute: em, second: 0, millisecond: 0 });
      const iv = Interval.fromDateTimes(winStart, winEnd);
      if (iv.isValid) dayIntervals.push(iv);
    }
    if (dayIntervals.length) {
      const free = subtractIntervals(dayIntervals, blockerIntervals);
      allSlots.push(...sliceIntoSlots(free, slotMinutes, bufferMinutes));
    }
    cursor = cursor.plus({ days: 1 });
  }

  // Drop slots already in the past
  const now = DateTime.now().setZone(tz);
  return allSlots.filter(s => DateTime.fromISO(s.start).setZone(tz) > now);
}

// Verify that a concrete [start,end] window lies entirely inside one of the
// installer's recurring availability windows for that weekday, and does not
// intersect any time-off block. Used by the booking POST so a directly-posted
// scheduled_start/scheduled_end can't fall outside published hours (e.g. 3am
// on a day the installer doesn't work). Conflict-with-other-bookings is
// checked separately under the row lock.
async function isWithinAvailability(installerId, startJsDate, endJsDate, opts = {}) {
  const tz = opts.timezone || DEFAULT_TZ;
  const start = DateTime.fromJSDate(startJsDate).setZone(tz);
  const end   = DateTime.fromJSDate(endJsDate).setZone(tz);
  if (!start.isValid || !end.isValid || end <= start) return false;
  // A booking must not straddle a calendar day — availability windows are
  // per-weekday, so a cross-midnight request can't be inside any one window.
  if (!start.hasSame(end, 'day')) return false;

  const avail = await getAvailability(installerId);
  if (avail.length === 0) return false;

  const dow = start.weekday === 7 ? 0 : start.weekday;
  const todayWindows = avail.filter(a => a.day_of_week === dow);
  if (todayWindows.length === 0) return false;

  const insideWindow = todayWindows.some(w => {
    const [sh, sm] = w.start_time.split(':').map(Number);
    const [eh, em] = w.end_time.split(':').map(Number);
    const winStart = start.set({ hour: sh, minute: sm, second: 0, millisecond: 0 });
    const winEnd   = start.set({ hour: eh, minute: em, second: 0, millisecond: 0 });
    return start >= winStart && end <= winEnd;
  });
  if (!insideWindow) return false;

  const timeOff = await getTimeOff(installerId, start.toUTC().toISO(), end.toUTC().toISO());
  const requested = Interval.fromDateTimes(start, end);
  const blockedByTimeOff = timeOff.some(t => {
    const iv = Interval.fromDateTimes(
      DateTime.fromJSDate(t.start_at).setZone(tz),
      DateTime.fromJSDate(t.end_at).setZone(tz)
    );
    return iv.isValid && iv.overlaps(requested);
  });
  return !blockedByTimeOff;
}

module.exports = { availableSlots, isWithinAvailability };