/opt/mawid/apps/api/src/agent
Edit: /opt/mawid/apps/api/src/agent/availability.ts (5183B)
/**
* Availability engine — pure functions, no I/O (PROJECT_PLAN Phase 3 task 1).
* working hours × staff × service duration × existing appointments → free slots.
*/
import { utcToWallTime, wallTimeToUtc, type WeekdayKey } from './tz';
export interface HourInterval {
start: string; // HH:MM clinic-local
end: string;
}
export type WeekHours = Partial
>;
export interface AvailabilityStaff {
id: string;
name: string;
workingHours: WeekHours;
}
export interface BusyInterval {
staffId: string;
startsAt: Date;
endsAt: Date;
}
export interface Slot {
staffId: string;
staffName: string;
startsAt: Date; // UTC
endsAt: Date; // UTC
}
export interface AvailabilityQuery {
timezone: string;
durationMinutes: number;
staff: AvailabilityStaff[];
/** Confirmed/pending appointments that block slots. */
busy: BusyInterval[];
/** UTC bounds; slots must start at/after `from` and end at/before `to`. */
from: Date;
to: Date;
/** Slot grid step. Defaults to 15 minutes. */
granularityMinutes?: number;
/** Fallback when a staff member has no working hours of their own. */
clinicHours?: WeekHours;
}
const DAY_MS = 24 * 60 * 60 * 1000;
function parseHhMm(value: string): { hour: number; minute: number } | null {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) return null;
return { hour: Number(match[1]), minute: Number(match[2]) };
}
function hasHours(hours: WeekHours | undefined): boolean {
return !!hours && Object.values(hours).some((intervals) => (intervals ?? []).length > 0);
}
export function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
return aStart < bEnd && bStart < aEnd;
}
export function computeAvailableSlots(query: AvailabilityQuery): Slot[] {
const {
timezone,
durationMinutes,
staff,
busy,
from,
to,
granularityMinutes = 15,
clinicHours,
} = query;
if (durationMinutes <= 0 || granularityMinutes <= 0 || from >= to) return [];
const busyByStaff = new Map();
for (const b of busy) {
const list = busyByStaff.get(b.staffId) ?? [];
list.push(b);
busyByStaff.set(b.staffId, list);
}
const slots: Slot[] = [];
// DST spring-forward folds nonexistent wall times (e.g. 02:30) onto the same
// instant as a later valid time — track emitted instants per staff to dedupe.
const seenSlots = new Set();
// Iterate clinic-local days covering [from, to]. Walking in UTC-day steps and
// converting each to a local date is DST-safe.
const seenDays = new Set();
for (let ts = from.getTime() - DAY_MS; ts <= to.getTime() + DAY_MS; ts += DAY_MS) {
const local = utcToWallTime(timezone, new Date(ts));
const dayKey = `${local.year}-${local.month}-${local.day}`;
if (seenDays.has(dayKey)) continue;
seenDays.add(dayKey);
for (const member of staff) {
const week = hasHours(member.workingHours) ? member.workingHours : (clinicHours ?? {});
const intervals = week[local.weekday] ?? [];
for (const interval of intervals) {
const start = parseHhMm(interval.start);
const end = parseHhMm(interval.end);
if (!start || !end) continue;
const intervalEndUtc = wallTimeToUtc(
timezone,
local.year,
local.month,
local.day,
end.hour,
end.minute,
);
for (
let minutes = start.hour * 60 + start.minute;
minutes + durationMinutes <= end.hour * 60 + end.minute;
minutes += granularityMinutes
) {
const slotStart = wallTimeToUtc(
timezone,
local.year,
local.month,
local.day,
Math.floor(minutes / 60),
minutes % 60,
);
const slotEnd = new Date(slotStart.getTime() + durationMinutes * 60_000);
if (slotStart < from || slotEnd > to) continue;
// DST spring-forward can fold nonexistent wall times onto later
// instants — drop anything escaping its working interval.
if (slotEnd > intervalEndUtc) continue;
const slotKey = `${member.id}|${slotStart.getTime()}`;
if (seenSlots.has(slotKey)) continue;
seenSlots.add(slotKey);
const blocked = (busyByStaff.get(member.id) ?? []).some((b) =>
overlaps(slotStart, slotEnd, b.startsAt, b.endsAt),
);
if (!blocked) {
slots.push({ staffId: member.id, staffName: member.name, startsAt: slotStart, endsAt: slotEnd });
}
}
}
}
}
slots.sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime() || a.staffId.localeCompare(b.staffId));
return slots;
}
/** True when [startsAt, endsAt) is one of the computable slots for that staff. */
export function isSlotAvailable(
query: AvailabilityQuery,
staffId: string,
startsAt: Date,
endsAt: Date,
): boolean {
return computeAvailableSlots(query).some(
(s) =>
s.staffId === staffId &&
s.startsAt.getTime() === startsAt.getTime() &&
s.endsAt.getTime() === endsAt.getTime(),
);
}