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:
- Os intervalos brutos de tempo estão fragmentados e bagunçados.
- Salvar novos intervalos pode deixar linhas fragmentadas no banco.
- 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:
Intervalos brutos de tempo
-> intervalos diários mesclados
-> plano seguro de persistência
-> disponibilidade compartilhada em grupoO 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:
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:00vira54013:30vira81017:00vira1020
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:
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:
- Ordene por
startMinem ordem crescente. - Em caso de empate, ordene por
endMinem ordem decrescente. - Inicie o resultado com o primeiro intervalo.
- Percorra adiante e faça merge com o último intervalo ou adicione um novo.
O detalhe deliberado é esta comparação:
sorted[i].startMin <= last.endMinUsar <= significa que intervalos adjacentes também entram no merge.
Então isto tudo colapsa em um único bloco:
09:00-11:00
10:30-12:00
12:00-13:00Resultado:
09:00-13:00Mas intervalos claramente separados continuam separados:
14:00-15:00
16:00-17:00Resultado:
14:00-15:00
16:00-17:00Por 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:
Seg 09:00-11:00
Seg 13:00-14:00Depois o usuário adiciona:
Seg 10:30-13:00Agora 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.
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 é:
- Identificar os dias afetados pelos novos intervalos.
- Buscar as linhas existentes para cada dia afetado.
- Combinar intervalos existentes e novos.
- Fazer merge em blocos normalizados.
- Apagar as linhas antigas daquele dia.
- Inserir as substituições normalizadas.
Para o exemplo anterior de segunda-feira:
Existentes:
- Seg 09:00-11:00 (id: a1)
- Seg 13:00-14:00 (id: a2)
Novos:
- Seg 10:30-13:00Resultado do merge:
Seg 09:00-14:00Plano de escrita:
{
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:
a.start < b.end AND b.start < a.endEssa regra exclui intervalos que apenas encostam na borda.
Então:
09:00-11:00e10:00-12:00se sobrepõem09:00-11:00e11:00-12:00nã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:
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 é:
start = max(a.start, b.start)
end = min(a.end, b.end)Exemplo:
A: Seg 09:00-12:00
B: Seg 10:30-11:30Resultado:
Seg 10:30-11:30Exemplo de intervalos que só encostam:
A: Seg 09:00-11:00
B: Seg 11:00-13:00Resultado:
[]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.
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:
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:
[
{ dayOfWeek: 1, startMin: 600, endMin: 630 },
{ dayOfWeek: 1, startMin: 930, endMin: 960 },
]O que significa:
Seg 10:00-10:30
Seg 15:30-16:00Essa é 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:
Seg 09:00-10:00
Seg 10:00-11:00
Seg 10:30-12:00E o participante B tenha:
Seg 10:45-11:15Se 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:
Seg 09:00-12:00E agora o intervalo compartilhado fica inequívoco:
Seg 10:45-11:15É por isso que o fluxo deve ser:
intervalos bagunçados
-> intervalos mesclados
-> plano de persistência
-> interseção em pares
-> sobreposição em grupoE 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:
O(n log n)intersectTwo() usa loops aninhados:
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
dayOfWeekprimeiro - 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:
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:
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:
- fazer merge de intervalos bagunçados ou adjacentes
- persistir intervalos diários normalizados com segurança
- 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.
