Skip to content

Cómo encontrar disponibilidad compartida en TypeScript fusionando franjas horarias

Introducción

Los calendarios desordenados crean un problema de scheduling engañosamente difícil.

Una persona tiene 09:00-11:00 y 13:00-15:00. Otra tiene 10:30-12:00 y 14:00-16:00. Un recurso está bloqueado durante parte de la tarde. Ahora necesitas encontrar una coincidencia utilizable rápido, no solo comparar un par de intervalos limpios en papel.

La pregunta de producto es simple:

"¿Cuándo están libres todos?"

La implementación normalmente no lo es.

Cuando empiezas a construir un scheduler, un flujo de reservas, un planificador de reuniones o una función de disponibilidad, enseguida te encuentras con tres problemas distintos:

  1. Las franjas horarias crudas están fragmentadas y desordenadas.
  2. Guardar nuevas franjas puede dejar filas fragmentadas en tu base de datos.
  3. La disponibilidad compartida solo funciona si puedes intersectar de forma fiable múltiples horarios, calendarios o recursos.

Me encontré con esto mientras trabajaba en un flujo de scheduling en React Native, pero la lógica subyacente no tiene nada que ver con React Native. El mismo algoritmo aplica a apps de reservas, herramientas de coordinación de turnos, disponibilidad en marketplaces, selectores de citas y funciones de calendario para equipos.

En este artículo construiremos un pequeño conjunto de utilidades en TypeScript que cubre todo el recorrido:

text
Franjas horarias crudas
    -> rangos diarios fusionados
    -> plan de persistencia seguro
    -> disponibilidad compartida para un grupo

La ganancia principal es groupOverlap(), que responde la pregunta orientada al usuario. Pero para que esa respuesta sea confiable, primero tenemos que normalizar franjas horarias superpuestas con mergeTimeRanges() y calcular un plan limpio de escritura con computeMergePlan().

Representa las franjas horarias de forma explícita

Para disponibilidad semanal recurrente, un modelo compacto funciona muy bien:

ts
interface Slot {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

interface TimeRange {
  startMin: number;
  endMin: number;
}

La decisión clave aquí es usar minutos desde medianoche en lugar de strings como "09:30" o de objetos Date completos.

Eso te da varios beneficios prácticos:

  • las comparaciones son numéricas
  • ordenar es trivial
  • las validaciones de solapamiento se vuelven desigualdades de una línea
  • el formateo para UI queda separado de la lógica de scheduling

Entonces:

  • 09:00 se convierte en 540
  • 13:30 se convierte en 810
  • 17:00 se convierte en 1020

Este artículo asume lo siguiente:

  • la conversión de zona horaria ya ocurrió aguas arriba
  • cada franja pertenece a un solo día
  • la disponibilidad que cruza medianoche se divide antes de llegar a esta utilidad
  • los rangos se comportan como intervalos semiabiertos para la lógica de intersección

Ese último punto importa. Una franja que termina a las 11:00 y otra que empieza a las 11:00 se considerarán adyacentes, no superpuestas. Las fusionaremos para almacenamiento, pero no las contaremos como disponibilidad compartida al intersectar horarios.

Paso 1: Fusiona franjas horarias superpuestas y adyacentes

Antes de calcular disponibilidad compartida, necesitas rangos diarios limpios.

La entrada real suele estar fragmentada:

  • un usuario toca 09:00-11:00
  • luego agrega 10:30-12:00
  • luego agrega 12:00-13:00

Semánticamente, eso es un solo bloque: 09:00-13:00.

Si dejas eso como filas separadas, todo lo que viene después se complica:

  • los resultados de solapamiento son más ruidosos
  • las reglas de persistencia se vuelven más difíciles de razonar
  • siguen apareciendo rangos duplicados o que apenas se tocan en los bordes

Aquí está la función de fusión:

ts
interface TimeRange {
  startMin: number;
  endMin: number;
}

export function mergeTimeRanges(ranges: TimeRange[]): TimeRange[] {
  if (ranges.length <= 1) return ranges.map((r) => ({ ...r }));

  const sorted = [...ranges].sort(
    (a, b) => a.startMin - b.startMin || b.endMin - a.endMin,
  );

  const merged: TimeRange[] = [{ ...sorted[0] }];

  for (let i = 1; i < sorted.length; i++) {
    const last = merged[merged.length - 1];

    if (sorted[i].startMin <= last.endMin) {
      last.endMin = Math.max(last.endMin, sorted[i].endMin);
    } else {
      merged.push({ ...sorted[i] });
    }
  }

  return merged;
}

El algoritmo es directo:

  1. Ordena por startMin ascendente.
  2. En empates, ordena por endMin descendente.
  3. Inicializa el resultado con el primer rango.
  4. Recorre hacia adelante y fusiona con el último rango o agrega uno nuevo.

El detalle deliberado es esta comparación:

ts
sorted[i].startMin <= last.endMin

Usar <= significa que las franjas adyacentes también se fusionan.

Así que esto colapsa en un solo bloque:

text
09:00-11:00
10:30-12:00
12:00-13:00

Resultado:

text
09:00-13:00

Pero los rangos claramente separados siguen separados:

text
14:00-15:00
16:00-17:00

Resultado:

text
14:00-15:00
16:00-17:00

¿Por qué ordenar con a.startMin - b.startMin || b.endMin - a.endMin en lugar de ordenar solo por inicio?

Porque cuando dos rangos empiezan en el mismo minuto, poner primero el más largo hace que el recorrido de fusión sea más predecible. Por ejemplo, 09:00-13:00 debería ir antes que 09:00-10:00.

TIP

Si tu UI permite a los usuarios seleccionar tiempo en bloques pequeños, fusionar franjas adyacentes normalmente refleja mejor la intención del usuario que preservar límites artificiales.

Paso 2: Convierte franjas normalizadas en un plan de persistencia seguro

Fusionar rangos en memoria es solo una parte del problema.

En producción, muchas veces ya tienes filas guardadas como estas:

text
Lun 09:00-11:00
Lun 13:00-14:00

Luego el usuario agrega:

text
Lun 10:30-13:00

Ahora no solo necesitas la disponibilidad final fusionada. Necesitas un plan de escritura determinista:

  • qué filas existentes deben eliminarse
  • qué filas normalizadas deben insertarse

Eso es lo que hace computeMergePlan().

ts
interface ExistingSlot {
  id: string;
  day_of_week: number;
  start_min: number;
  end_min: number;
}

interface NewRange {
  day_of_week: number;
  start_min: number;
  end_min: number;
}

export function computeMergePlan(
  existingSlots: ExistingSlot[],
  newRanges: NewRange[],
): { toDelete: string[]; toInsert: NewRange[] } {
  const affectedDays = new Set(newRanges.map((r) => r.day_of_week));
  const toDelete: string[] = [];
  const toInsert: NewRange[] = [];

  for (const day of affectedDays) {
    const existingOnDay = existingSlots.filter((s) => s.day_of_week === day);
    const newOnDay = newRanges.filter((r) => r.day_of_week === day);

    const allRanges: TimeRange[] = [
      ...existingOnDay.map((s) => ({
        startMin: s.start_min,
        endMin: s.end_min,
      })),
      ...newOnDay.map((r) => ({
        startMin: r.start_min,
        endMin: r.end_min,
      })),
    ];

    const merged = mergeTimeRanges(allRanges);

    toDelete.push(...existingOnDay.map((s) => s.id));
    toInsert.push(
      ...merged.map((m) => ({
        day_of_week: day,
        start_min: m.startMin,
        end_min: m.endMin,
      })),
    );
  }

  return { toDelete, toInsert };
}

El flujo es:

  1. Identificar los días tocados por los rangos entrantes.
  2. Obtener las filas existentes para cada día afectado.
  3. Combinar rangos existentes y nuevos.
  4. Fusionarlos en bloques normalizados.
  5. Eliminar las filas viejas de ese día.
  6. Insertar los reemplazos normalizados.

Para el ejemplo anterior del lunes:

text
Existentes:
- Lun 09:00-11:00   (id: a1)
- Lun 13:00-14:00   (id: a2)

Nuevos:
- Lun 10:30-13:00

Resultado fusionado:

text
Lun 09:00-14:00

Plan de escritura:

ts
{
  toDelete: ['a1', 'a2'],
  toInsert: [
    { day_of_week: 1, start_min: 540, end_min: 840 }
  ]
}

Esto suele ser más seguro que intentar parchear filas en el mismo lugar con una pila creciente de actualizaciones condicionales.

El tradeoff es deliberado:

  • puede que hagas más escrituras de las estrictamente necesarias
  • pero obtienes almacenamiento determinista y normalizado

Eso casi siempre vale la pena para datos de scheduling.

WARNING

computeMergePlan() solo calcula las operaciones de eliminación e inserción. La transacción real pertenece a la capa de aplicación.

Paso 3: Define la intersección de intervalos por pares

Ahora sí podemos movernos al problema real de disponibilidad compartida.

Antes de manejar un grupo, define la regla de solapamiento para dos horarios.

Dos rangos de tiempo se solapan cuando:

text
a.start < b.end AND b.start < a.end

Esa regla excluye los rangos que solo se tocan en el borde.

Entonces:

  • 09:00-11:00 y 10:00-12:00 sí se solapan
  • 09:00-11:00 y 11:00-12:00 no se solapan

Esa distinción es importante. Una persona que deja de estar disponible a las 11:00 no está disponible para una franja compartida que empieza exactamente a las 11:00.

Aquí está la implementación por pares:

ts
interface Slot {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

interface OverlapResult {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

export function intersectTwo(a: Slot[], b: Slot[]): OverlapResult[] {
  const result: OverlapResult[] = [];

  for (const sa of a) {
    for (const sb of b) {
      if (
        sa.dayOfWeek === sb.dayOfWeek &&
        sa.startMin < sb.endMin &&
        sb.startMin < sa.endMin
      ) {
        result.push({
          dayOfWeek: sa.dayOfWeek,
          startMin: Math.max(sa.startMin, sb.startMin),
          endMin: Math.min(sa.endMin, sb.endMin),
        });
      }
    }
  }

  return result;
}

Una vez que la condición de solapamiento se cumple, el intervalo compartido es:

text
start = max(a.start, b.start)
end   = min(a.end, b.end)

Ejemplo:

text
A: Lun 09:00-12:00
B: Lun 10:30-11:30

Resultado:

text
Lun 10:30-11:30

Ejemplo de toque sin solapamiento:

text
A: Lun 09:00-11:00
B: Lun 11:00-13:00

Resultado:

text
[]

Esta primitiva por pares es la base de todo lo demás.

Paso 4: Convierte groupOverlap() en el héroe

Una vez que puedes intersectar dos horarios, encontrar disponibilidad compartida en un grupo se convierte en un problema de reducción.

Empieza con las franjas del primer participante, intersecta con el segundo, intersecta ese resultado con el tercero y sigue así hasta incluir a todos.

ts
export function groupOverlap(allSlots: Slot[][]): OverlapResult[] {
  if (allSlots.length === 0) return [];
  return allSlots.reduce((acc, slots) => intersectTwo(acc, slots));
}

Esa función diminuta carga con el valor principal de producto.

Si le pasas la disponibilidad semanal de varios participantes, devuelve los rangos de tiempo donde todos están disponibles.

Veamos un ejemplo:

ts
const personA: Slot[] = [
  { dayOfWeek: 1, startMin: 540, endMin: 720 },   // Mon 09:00-12:00
  { dayOfWeek: 1, startMin: 840, endMin: 1020 },  // Mon 14:00-17:00
];

const personB: Slot[] = [
  { dayOfWeek: 1, startMin: 600, endMin: 690 },   // Mon 10:00-11:30
  { dayOfWeek: 1, startMin: 900, endMin: 1080 },  // Mon 15:00-18:00
];

const personC: Slot[] = [
  { dayOfWeek: 1, startMin: 480, endMin: 630 },   // Mon 08:00-10:30
  { dayOfWeek: 1, startMin: 930, endMin: 960 },   // Mon 15:30-16:00
];

const overlap = groupOverlap([personA, personB, personC]);

Resultado:

ts
[
  { dayOfWeek: 1, startMin: 600, endMin: 630 },
  { dayOfWeek: 1, startMin: 930, endMin: 960 },
]

Lo que significa:

text
Lun 10:00-10:30
Lun 15:30-16:00

Esa es la respuesta de disponibilidad compartida que tu UI puede renderizar directamente.

Algunos edge cases salen de forma natural:

  • sin participantes -> []
  • un participante -> sus franjas vuelven tal cual
  • una persona sin ventana compatible -> el resultado colapsa a []

Lo importante es que groupOverlap() se mantiene simple porque la normalización ya ocurrió antes en el flujo.

Por qué la normalización va antes de la intersección

Puede ser tentador saltar directamente al algoritmo de intersección de intervalos. Pero muchos bugs de scheduling empiezan antes, con datos fuente desordenados.

Imagina que el participante A tiene esta disponibilidad guardada:

text
Lun 09:00-10:00
Lun 10:00-11:00
Lun 10:30-12:00

Y el participante B tiene:

text
Lun 10:45-11:15

Si intersectas primero y normalizas después, corres el riesgo de devolver múltiples fragmentos para lo que conceptualmente es una sola respuesta continua.

Si normalizas primero, el participante A se convierte en:

text
Lun 09:00-12:00

Y ahora la franja compartida es inequívoca:

text
Lun 10:45-11:15

Por eso el flujo debería ser:

text
franjas desordenadas
-> rangos fusionados
-> plan de persistencia
-> intersección por pares
-> solapamiento grupal

No al revés.

Notas sobre complejidad y escala

Para scheduling guiado por UI, este enfoque suele ser más que suficiente en rendimiento.

mergeTimeRanges() está dominada por el ordenamiento:

text
O(n log n)

intersectTwo() usa bucles anidados:

text
O(a * b)

Y groupOverlap() aplica esa intersección por pares a lo largo de la lista de participantes.

En pantallas típicas de reservas y disponibilidad, la cantidad de franjas suele ser pequeña:

  • unas pocas ventanas por día
  • quizá 7 días
  • quizá unos cuantos participantes

En ese mundo, el código anterior es fácil de entender y suficientemente rápido.

Si tu dataset crece, quizá quieras optimizar:

  • agrupar franjas por dayOfWeek primero
  • mantener los rangos de cada día ordenados
  • reemplazar bucles anidados por un recorrido con dos punteros
  • expandir reglas recurrentes antes de intersectar, no durante

Pero empieza con la versión simple a menos que el profiling te diga lo contrario.

Lo que esta utilidad no resuelve

Este algoritmo de disponibilidad compartida es intencionalmente acotado.

No maneja:

  • conversión de zonas horarias
  • expansión de recurrencia desde datos de calendario estilo RRULE
  • franjas que cruzan medianoche como 23:00-02:00
  • ranking de ventanas candidatas por preferencia
  • buffers de traslado o requisitos de hueco mínimo
  • conflictos de calendario traídos desde proveedores externos

Eso es una feature, no una debilidad.

Los buenos sistemas de scheduling suelen separar responsabilidades:

  • primero convierten y normalizan los datos de tiempo
  • luego intersectan disponibilidad
  • después rankean o presentan franjas candidatas

Intentar colapsarlo todo en una única función "inteligente" normalmente produce código más difícil de testear y más fácil de romper.

Cómo testear el flujo de scheduling

La lógica de intervalos es exactamente el tipo de código que merece tests enfocados.

No necesitas fixtures enormes. Un puñado de casos orientados a bordes te da mucha confianza.

Aquí tienes buenos ejemplos con sintaxis estilo Vitest:

ts
import { describe, expect, it } from 'vitest';
import {
  computeMergePlan,
  groupOverlap,
  intersectTwo,
  mergeTimeRanges,
} from './overlap';

describe('mergeTimeRanges', () => {
  it('merges overlapping and adjacent ranges', () => {
    expect(
      mergeTimeRanges([
        { startMin: 540, endMin: 660 },
        { startMin: 630, endMin: 720 },
        { startMin: 720, endMin: 780 },
      ]),
    ).toEqual([{ startMin: 540, endMin: 780 }]);
  });

  it('keeps separated ranges apart', () => {
    expect(
      mergeTimeRanges([
        { startMin: 540, endMin: 600 },
        { startMin: 660, endMin: 720 },
      ]),
    ).toEqual([
      { startMin: 540, endMin: 600 },
      { startMin: 660, endMin: 720 },
    ]);
  });
});

describe('computeMergePlan', () => {
  it('replaces affected-day rows with normalized inserts', () => {
    expect(
      computeMergePlan(
        [
          { id: 'a1', day_of_week: 1, start_min: 540, end_min: 660 },
          { id: 'a2', day_of_week: 1, start_min: 780, end_min: 840 },
        ],
        [{ day_of_week: 1, start_min: 630, end_min: 780 }],
      ),
    ).toEqual({
      toDelete: ['a1', 'a2'],
      toInsert: [{ day_of_week: 1, start_min: 540, end_min: 840 }],
    });
  });
});

describe('intersectTwo', () => {
  it('does not count edge-touching ranges as overlap', () => {
    expect(
      intersectTwo(
        [{ dayOfWeek: 1, startMin: 540, endMin: 660 }],
        [{ dayOfWeek: 1, startMin: 660, endMin: 720 }],
      ),
    ).toEqual([]);
  });
});

describe('groupOverlap', () => {
  it('finds shared availability across multiple participants', () => {
    expect(
      groupOverlap([
        [{ dayOfWeek: 1, startMin: 540, endMin: 720 }],
        [{ dayOfWeek: 1, startMin: 600, endMin: 690 }],
        [{ dayOfWeek: 1, startMin: 630, endMin: 660 }],
      ]),
    ).toEqual([{ dayOfWeek: 1, startMin: 630, endMin: 660 }]);
  });
});

Estos tests cubren los riesgos principales del scheduling:

  • las franjas superpuestas se fusionan correctamente
  • las franjas adyacentes se fusionan correctamente
  • los rangos separados permanecen separados
  • la persistencia por día afectado es determinista
  • las franjas que solo se tocan no producen falsos solapamientos
  • la disponibilidad compartida en grupo colapsa como se espera

Si estás construyendo un sistema de reservas, estos no son detalles opcionales. Protegen exactamente la lógica en la que tus usuarios confiarán cuando intenten agendar tiempo real con otras personas.

Implementación final

Uniéndolo todo:

ts
interface Slot {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

interface TimeRange {
  startMin: number;
  endMin: number;
}

interface ExistingSlot {
  id: string;
  day_of_week: number;
  start_min: number;
  end_min: number;
}

interface NewRange {
  day_of_week: number;
  start_min: number;
  end_min: number;
}

interface OverlapResult {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

export function mergeTimeRanges(ranges: TimeRange[]): TimeRange[] {
  if (ranges.length <= 1) return ranges.map((r) => ({ ...r }));

  const sorted = [...ranges].sort(
    (a, b) => a.startMin - b.startMin || b.endMin - a.endMin,
  );

  const merged: TimeRange[] = [{ ...sorted[0] }];

  for (let i = 1; i < sorted.length; i++) {
    const last = merged[merged.length - 1];

    if (sorted[i].startMin <= last.endMin) {
      last.endMin = Math.max(last.endMin, sorted[i].endMin);
    } else {
      merged.push({ ...sorted[i] });
    }
  }

  return merged;
}

export function computeMergePlan(
  existingSlots: ExistingSlot[],
  newRanges: NewRange[],
): { toDelete: string[]; toInsert: NewRange[] } {
  const affectedDays = new Set(newRanges.map((r) => r.day_of_week));
  const toDelete: string[] = [];
  const toInsert: NewRange[] = [];

  for (const day of affectedDays) {
    const existingOnDay = existingSlots.filter((s) => s.day_of_week === day);
    const newOnDay = newRanges.filter((r) => r.day_of_week === day);

    const allRanges: TimeRange[] = [
      ...existingOnDay.map((s) => ({
        startMin: s.start_min,
        endMin: s.end_min,
      })),
      ...newOnDay.map((r) => ({
        startMin: r.start_min,
        endMin: r.end_min,
      })),
    ];

    const merged = mergeTimeRanges(allRanges);

    toDelete.push(...existingOnDay.map((s) => s.id));
    toInsert.push(
      ...merged.map((m) => ({
        day_of_week: day,
        start_min: m.startMin,
        end_min: m.endMin,
      })),
    );
  }

  return { toDelete, toInsert };
}

export function intersectTwo(a: Slot[], b: Slot[]): OverlapResult[] {
  const result: OverlapResult[] = [];

  for (const sa of a) {
    for (const sb of b) {
      if (
        sa.dayOfWeek === sb.dayOfWeek &&
        sa.startMin < sb.endMin &&
        sb.startMin < sa.endMin
      ) {
        result.push({
          dayOfWeek: sa.dayOfWeek,
          startMin: Math.max(sa.startMin, sb.startMin),
          endMin: Math.min(sa.endMin, sb.endMin),
        });
      }
    }
  }

  return result;
}

export function groupOverlap(allSlots: Slot[][]): OverlapResult[] {
  if (allSlots.length === 0) return [];
  return allSlots.reduce((acc, slots) => intersectTwo(acc, slots));
}

Conclusión

Si quieres una disponibilidad compartida confiable, el problema real es más grande que la intersección de intervalos por sí sola.

Necesitas:

  1. fusionar franjas desordenadas o adyacentes
  2. persistir rangos diarios normalizados de forma segura
  3. intersectar horarios hasta que solo quede el tiempo libre en común

Por eso groupOverlap() funciona mejor como capa final, no como la primera.

Con estas cuatro funciones pequeñas puedes soportar una cantidad sorprendente de features de scheduling:

  • reserva de citas
  • coordinación de reuniones
  • planificación de salidas con amigos
  • matching de turnos
  • disponibilidad de marketplace

Desde aquí, las siguientes extensiones útiles suelen ser:

  • rankear ventanas compartidas por duración
  • agregar buffers entre citas
  • soportar reglas de disponibilidad recurrente

Pero el núcleo sigue siendo el mismo:

franjas limpias entran, disponibilidad compartida confiable sale.