← 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 };