Motor de disponibilidad de citas. Le das el horario semanal de un negocio y lo que ya está ocupado, y te devuelve los huecos libres.
Es una función pura: sin red, sin base de datos, sin leer el reloj. Todo lo que necesita entra por parámetro, así que se puede testear entera y razonar sobre ella sin montar nada.
- Cero dependencias. Solo
Intl, que ya viene en el runtime. - Correcto en los cambios de hora. Un negocio que abre a las 9:00 abre a las 9:00 también el lunes siguiente al cambio de horario — aunque en UTC sea otra hora distinta.
- TypeScript estricto, con
noUncheckedIndexedAccess. - 32 tests, incluidos los casos de horario de verano y las zonas con desfase de media hora.
Extraído de Foreus, donde decide si un agente de voz le promete o no una hora a un paciente que ha llamado por teléfono.
npm install slots-engineRequiere Node 22 o superior.
import { findFreeSlots, isSlotAvailable, type BusinessHours } from "slots-engine";
// Lunes a viernes de 9 a 14 y de 16 a 20. Sábado por la mañana. Domingo cerrado.
const horario: BusinessHours = {
monday: [{ start: "09:00", end: "14:00" }, { start: "16:00", end: "20:00" }],
tuesday: [{ start: "09:00", end: "14:00" }, { start: "16:00", end: "20:00" }],
wednesday: [{ start: "09:00", end: "14:00" }, { start: "16:00", end: "20:00" }],
thursday: [{ start: "09:00", end: "14:00" }, { start: "16:00", end: "20:00" }],
friday: [{ start: "09:00", end: "14:00" }],
saturday: [{ start: "10:00", end: "14:00" }],
};
const huecos = findFreeSlots({
from: new Date(),
to: new Date(Date.now() + 7 * 86_400_000),
timeZone: "Europe/Madrid",
hours: horario,
busy: [ // citas ya reservadas
{ start: new Date("2026-08-17T08:00:00Z"), end: new Date("2026-08-17T09:00:00Z") },
],
durationMinutes: 45,
limit: 3,
});
// → [Date, Date, Date] — los tres primeros inicios libresY antes de escribir la reserva en la base de datos:
const sigueLibre = isSlotAvailable({
start: huecoElegido,
durationMinutes: 45,
timeZone: "Europe/Madrid",
hours: horario,
busy: ocupacionActual, // releída justo ahora
});Los huecos que propone findFreeSlots pueden haber sido ocupados mientras el cliente decidía.
isSlotAvailable es la comprobación de última hora — y aun así, la garantía real de que no habrá
dos reservas para el mismo momento es un índice único en la base de datos. Esta librería
calcula; no serializa.
Devuelve los inicios de cita libres, alineados a la granularidad y con espacio para la duración completa.
| Campo | Tipo | Por defecto | Qué es |
|---|---|---|---|
from / to |
Date |
— | Ventana de búsqueda. Nunca se proponen huecos anteriores a from. |
timeZone |
string |
— | Zona IANA del negocio, p. ej. "Europe/Madrid". |
hours |
BusinessHours |
— | Horario semanal. Un día ausente o vacío es día cerrado. |
busy |
Interval[] |
— | Lo ya ocupado. No hace falta que venga ordenado ni sin solapes. |
durationMinutes |
number |
— | Duración de la cita a encajar. |
granularityMinutes |
number |
30 |
A qué marcas se alinean los inicios. |
limit |
number |
3 |
Cuántos devolver como máximo. |
¿Cabe una cita de durationMinutes empezando exactamente en start? Comprueba a la vez el
horario de atención y la ocupación.
businessIntervals(from, to, timeZone, hours)— el horario semanal convertido a intervalos absolutos en UTC.mergeIntervals(intervals)— une los solapados y los contiguos.subtractIntervals(base, busy)— resta la ocupación de la disponibilidad.
zonedParts, zonedTimeToUtc, startOfLocalDay, weekdayKey, localDateKey, parseHhMm,
minutesToHhMm, addMinutes.
Un Date de JavaScript es un instante absoluto, no una fecha de calendario. "Las 9:00 en Madrid"
son las 07:00 UTC en agosto y las 08:00 UTC en enero.
Por eso aquí nunca se suman 24 horas para avanzar un día: se recalcula el día de calendario
local y se vuelve a convertir a UTC. Y zonedTimeToUtc hace dos pasadas, porque el desfase
que hay que aplicar depende del propio instante que se está calculando — usar el desfase de la
medianoche daría una hora desplazada justo el día del cambio.
Es la clase de detalle que no falla en desarrollo y falla dos domingos al año en producción.
npm testMIT © Erik Goj