Skip to content

Como Encontrar Disponibilidade Compartilhada em TypeScript Fazendo Merge de Intervalos de Tempo

Introdução

Calendários bagunçados criam um problema de scheduling enganosamente difícil.

Uma pessoa tem 09:00-11:00 e 13:00-15:00. Outra tem 10:30-12:00 e 14:00-16:00. Um recurso fica bloqueado durante parte da tarde. Agora você precisa encontrar uma sobreposição utilizável rápido, não apenas comparar um par de intervalos limpos no papel.

A pergunta de produto é simples:

"Quando todo mundo está livre?"

A implementação normalmente não é.

Assim que você começa a construir um scheduler, fluxo de booking, planejador de reuniões ou recurso de disponibilidade, rapidamente esbarra em três problemas diferentes:

  1. Os intervalos brutos de tempo estão fragmentados e bagunçados.
  2. Salvar novos intervalos pode deixar linhas fragmentadas no banco.
  3. A disponibilidade compartilhada só funciona se você conseguir intersectar de forma confiável vários horários, calendários ou recursos.

Eu esbarrei nisso enquanto trabalhava em um fluxo de scheduling em React Native, mas a lógica por trás não tem nada a ver com React Native. O mesmo algoritmo se aplica a apps de booking, ferramentas de coordenação de turnos, disponibilidade em marketplaces, seletores de agendamento e recursos de calendário para times.

Neste artigo, vamos construir um pequeno conjunto de utilitários em TypeScript que cobre o fluxo completo:

text
Intervalos brutos de tempo
    -> intervalos diários mesclados
    -> plano seguro de persistência
    -> disponibilidade compartilhada em grupo

O principal payoff é groupOverlap(), que responde à pergunta voltada ao usuário. Mas para que essa resposta seja confiável, primeiro precisamos normalizar intervalos sobrepostos com mergeTimeRanges() e calcular um plano limpo de escrita com computeMergePlan().

Represente os intervalos de tempo de forma explícita

Para disponibilidade semanal recorrente, um modelo compacto funciona muito bem:

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

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

A decisão principal aqui é usar minutos desde a meia-noite em vez de strings como "09:30" ou objetos Date completos.

Isso traz alguns benefícios práticos:

  • as comparações são numéricas
  • ordenar é trivial
  • as checagens de sobreposição viram desigualdades de uma linha
  • a formatação para UI fica separada da lógica de scheduling

Então:

  • 09:00 vira 540
  • 13:30 vira 810
  • 17:00 vira 1020

Este artigo assume que:

  • a conversão de timezone já aconteceu antes
  • cada intervalo pertence a um único dia
  • disponibilidades que atravessam a meia-noite são divididas antes de chegar a este utilitário
  • os intervalos se comportam como intervalos semiabertos para a lógica de interseção

Esse último ponto importa. Um intervalo que termina às 11:00 e outro que começa às 11:00 será considerado adjacente, não sobreposto. Vamos mesclá-los para armazenamento, mas não vamos contá-los como disponibilidade compartilhada ao intersectar horários.

Etapa 1: Faça merge de intervalos sobrepostos e adjacentes

Antes de calcular disponibilidade compartilhada, você precisa de intervalos diários limpos.

Na prática, a entrada costuma vir fragmentada:

  • um usuário seleciona 09:00-11:00
  • depois adiciona 10:30-12:00
  • depois adiciona 12:00-13:00

Semanticamente, isso é um único bloco: 09:00-13:00.

Se você deixar isso como linhas separadas, tudo que vem depois fica mais difícil:

  • os resultados de sobreposição ficam mais ruidosos
  • as regras de persistência ficam mais difíceis de entender
  • intervalos duplicados ou que só encostam nas bordas continuam aparecendo

Aqui está a função de merge:

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

O algoritmo é direto:

  1. Ordene por startMin em ordem crescente.
  2. Em caso de empate, ordene por endMin em ordem decrescente.
  3. Inicie o resultado com o primeiro intervalo.
  4. Percorra adiante e faça merge com o último intervalo ou adicione um novo.

O detalhe deliberado é esta comparação:

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

Usar <= significa que intervalos adjacentes também entram no merge.

Então isto tudo colapsa em um único bloco:

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

Resultado:

text
09:00-13:00

Mas intervalos claramente separados continuam separados:

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

Resultado:

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

Por que ordenar com a.startMin - b.startMin || b.endMin - a.endMin em vez de ordenar só pelo início?

Porque, quando dois intervalos começam no mesmo minuto, colocar o maior primeiro torna a passada de merge mais previsível. Por exemplo, 09:00-13:00 deve vir antes de 09:00-10:00.

TIP

Se sua UI permite que usuários selecionem tempo em blocos pequenos, fazer merge de intervalos adjacentes normalmente combina melhor com a intenção do usuário do que preservar fronteiras artificiais.

Etapa 2: Transforme intervalos normalizados em um plano seguro de persistência

Fazer merge em memória é só uma parte do problema.

Em produção, você frequentemente já tem linhas salvas assim:

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

Depois o usuário adiciona:

text
Seg 10:30-13:00

Agora você não precisa apenas da disponibilidade final mesclada. Você precisa de um plano determinístico de escrita:

  • quais linhas existentes devem ser apagadas
  • quais linhas normalizadas devem ser inseridas

É exatamente isso que computeMergePlan() faz.

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

O fluxo é:

  1. Identificar os dias afetados pelos novos intervalos.
  2. Buscar as linhas existentes para cada dia afetado.
  3. Combinar intervalos existentes e novos.
  4. Fazer merge em blocos normalizados.
  5. Apagar as linhas antigas daquele dia.
  6. Inserir as substituições normalizadas.

Para o exemplo anterior de segunda-feira:

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

Novos:
- Seg 10:30-13:00

Resultado do merge:

text
Seg 09:00-14:00

Plano de escrita:

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

Isso normalmente é mais seguro do que tentar remendar linhas no lugar com uma pilha crescente de updates condicionais.

O tradeoff é deliberado:

  • você pode fazer mais writes do que o estritamente necessário
  • mas ganha armazenamento determinístico e normalizado

Quase sempre isso vale a pena para dados de scheduling.

WARNING

computeMergePlan() apenas calcula as operações de delete/insert. A transação real pertence à camada da sua aplicação.

Etapa 3: Defina a interseção de intervalos em pares

Agora podemos ir para o problema real de disponibilidade compartilhada.

Antes de lidar com um grupo, defina a regra de sobreposição para dois horários.

Dois intervalos de tempo se sobrepõem quando:

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

Essa regra exclui intervalos que apenas encostam na borda.

Então:

  • 09:00-11:00 e 10:00-12:00 se sobrepõem
  • 09:00-11:00 e 11:00-12:00 não se sobrepõem

Essa distinção importa. Uma pessoa que fica indisponível às 11:00 não está disponível para um intervalo compartilhado que começa exatamente às 11:00.

Aqui está a implementação em 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;
}

Quando a condição de sobreposição é verdadeira, o intervalo compartilhado é:

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

Exemplo:

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

Resultado:

text
Seg 10:30-11:30

Exemplo de intervalos que só encostam:

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

Resultado:

text
[]

Esse primitivo em pares é a base para todo o resto.

Etapa 4: Faça de groupOverlap() o herói

Depois que você consegue intersectar dois horários, encontrar disponibilidade compartilhada em grupo vira um problema de redução.

Comece com os intervalos do primeiro participante, intersecte com o segundo, intersecte o resultado com o terceiro e siga assim até incluir todo mundo.

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

Essa função minúscula carrega o principal valor de produto.

Se você passar a disponibilidade semanal de vários participantes, ela retorna os intervalos de tempo em que todos estão disponíveis.

Vamos ver um exemplo:

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 },
]

O que significa:

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

Essa é a resposta de disponibilidade compartilhada que sua UI pode renderizar diretamente.

Alguns edge cases aparecem naturalmente:

  • nenhum participante -> []
  • um participante -> os próprios intervalos dele voltam como estão
  • uma pessoa sem janela compatível -> o resultado colapsa para []

O importante é que groupOverlap() continua simples porque a normalização já aconteceu antes no fluxo.

Por que a normalização vem antes da interseção

Pode ser tentador pular direto para o algoritmo de interseção de intervalos. Mas bugs de scheduling muitas vezes começam antes, com dados de origem bagunçados.

Imagine que o participante A tenha esta disponibilidade salva:

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

E o participante B tenha:

text
Seg 10:45-11:15

Se você intersecta primeiro e normaliza depois, corre o risco de devolver vários fragmentos para algo que conceitualmente é uma única resposta contínua.

Se normalizar antes, o participante A vira:

text
Seg 09:00-12:00

E agora o intervalo compartilhado fica inequívoco:

text
Seg 10:45-11:15

É por isso que o fluxo deve ser:

text
intervalos bagunçados
-> intervalos mesclados
-> plano de persistência
-> interseção em pares
-> sobreposição em grupo

E não o contrário.

Notas sobre complexidade e escala

Para scheduling orientado por UI, essa abordagem normalmente é mais do que rápida o suficiente.

mergeTimeRanges() é dominada pela ordenação:

text
O(n log n)

intersectTwo() usa loops aninhados:

text
O(a * b)

E groupOverlap() aplica essa interseção em pares ao longo da lista de participantes.

Em telas típicas de booking e disponibilidade, a quantidade de intervalos é pequena:

  • algumas janelas por dia
  • talvez 7 dias
  • talvez alguns participantes

Nesse cenário, o código acima é fácil de entender e rápido o bastante.

Se seu dataset crescer, talvez valha otimizar:

  • agrupar intervalos por dayOfWeek primeiro
  • manter os intervalos de cada dia ordenados
  • trocar loops aninhados por um algoritmo de dois ponteiros
  • expandir regras recorrentes antes da interseção, não durante

Mas comece com a versão simples, a menos que profiling mostre o contrário.

O que este utilitário não resolve

Este algoritmo de disponibilidade compartilhada é intencionalmente limitado.

Ele não lida com:

  • conversão de timezone
  • expansão de recorrência a partir de dados de calendário no estilo RRULE
  • intervalos que atravessam a meia-noite, como 23:00-02:00
  • ranking de janelas candidatas por preferência
  • buffers de deslocamento ou requisitos de intervalo mínimo
  • conflitos de calendário vindos de provedores externos

Isso é uma feature, não uma fraqueza.

Bons sistemas de scheduling normalmente separam responsabilidades:

  • primeiro convertem e normalizam os dados de tempo
  • depois intersectam a disponibilidade
  • por fim ranqueiam ou apresentam janelas candidatas

Tentar colocar tudo em uma única função "inteligente" normalmente produz código mais difícil de testar e mais fácil de quebrar.

Testando o fluxo de scheduling

Lógica de intervalos é exatamente o tipo de código que merece testes focados.

Você não precisa de fixtures enormes. Um pequeno conjunto de casos orientados a borda já dá bastante confiança.

Aqui vão bons exemplos usando sintaxe no 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 }]);
  });
});

Esses testes cobrem os principais riscos de scheduling:

  • intervalos sobrepostos fazem merge corretamente
  • intervalos adjacentes fazem merge corretamente
  • intervalos separados continuam separados
  • a persistência por dia afetado é determinística
  • intervalos que só encostam na borda não geram falso overlap
  • a disponibilidade compartilhada em grupo colapsa como esperado

Se você está construindo um sistema de booking, esses testes não são luxo. Eles protegem exatamente a lógica em que seus usuários vão confiar quando tentarem agendar tempo real com outras pessoas.

Implementação final

Juntando tudo:

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

Conclusão

Se você quer disponibilidade compartilhada confiável, o problema real é maior do que interseção de intervalos isoladamente.

Você precisa:

  1. fazer merge de intervalos bagunçados ou adjacentes
  2. persistir intervalos diários normalizados com segurança
  3. intersectar horários até restar apenas o tempo livre em comum

É por isso que groupOverlap() funciona melhor como camada final, e não como a primeira.

Com essas quatro funções pequenas, você consegue suportar uma quantidade surpreendente de features de scheduling:

  • agendamento de consultas
  • coordenação de reuniões
  • planejamento de encontros com amigos
  • compatibilização de turnos
  • disponibilidade em marketplaces

Daqui para frente, as próximas extensões úteis normalmente são:

  • ranquear janelas compartilhadas por duração
  • adicionar buffers entre compromissos
  • suportar regras recorrentes de disponibilidade

Mas o núcleo continua o mesmo:

intervalos limpos entram, disponibilidade compartilhada confiável sai.