/opt/mawid/apps/api/src/agent
NameSizeModeActions
agent.e2e.spec.ts132960644editdlrm
agent.module.ts8320644editdlrm
agent.service.ts81630644editdlrm
availability.spec.ts84820644editdlrm
availability.ts51830644editdlrm
booking-tools.service.ts144400644editdlrm
openai.client.ts22470644editdlrm
system-prompt.ts18550644editdlrm
tool-definitions.ts36140644editdlrm
tz.ts28820644editdlrm
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(), ); }