Skip to content

Ночь, в которую нельзя записаться

Как Cal.com год чинил расписание, переходящее за полночь, и чем это кончилось

v2 0 stars 0 forks 0 watchers 1 branch 0 runs Public
Цитаты кода получили постоянные ссылки на строки (по SHA), шаги — refs на те же местаv2

Cal.com, раздел «когда я доступен», ноябрь 2022 — по сей день.

Cal.com — открытая замена Calendly: человек выставляет часы, когда он свободен, а гости сами занимают в них слоты. Часы задаются по дням недели: с такого-то до такого-то. Выпадающий список, шаг пятнадцать минут.

Пока вы работаете днём, всё честно.

Кадр первый: 18 ноября 2022. Четырнадцать минут

Приходит Ахмед Садман (ahmedsadman) и рассказывает, что работает по ночам — с 22:30 до 01:30 — и что в списке кончается время (#5587):

In the availability section, 11:46 PM to 11:59 PM gets totally wasted as there is no way to include that 15 min slot in my availability.

В разделе доступности промежуток с 23:46 до 23:59 пропадает впустую: его нечем включить в мои часы.

Последний пункт списка — 23:45. Дальше суток нет.

Кадр второй: 8 декабря 2022. Две строки вместо одной

Через три недели Удит Таккар (Udit-takkar) заводит ту же болезнь с другой стороны (#5929). Человек хочет быть доступным с 20:00 до 03:00. Приходится писать две строки:

8PM to 11:45PM

12:00 AM to 3:AM

Семь часов работы, записанные как два куска с дырой в четверть часа посредине.

Кадр третий: 21 декабря 2022. Починка за одиннадцать часов

Эмрис Эл (emrysal) присылает PR #6137 с названием, которое описывает ровно то, что делает:

Add 23:59 as a valid availability select option

Добавить 23:59 как допустимый пункт списка доступности.

Открыт в 05:05, влит в 16:13 того же дня. Четырнадцать потерянных минут возвращены. Задача #5929 закрыта.

Кадр четвёртый: 7 января 2023. Слота всё равно нет

Через две с половиной недели возвращается Ахмед Садман — тот же человек, что жаловался в ноябре, — и заводит #6323:

My selected availability is 10:30PM - 2:00AM. Given that, it should have a slot at 11:30PM so the meeting would be on 11:30PM - 12:30AM. But, the application doesn't allow booking at 11:30PM. I should mention that I have selected my availability up to 11:59PM for a day.

Мои часы — с 22:30 до 02:00. Значит, должен быть слот в 23:30, встреча шла бы с 23:30 до 00:30. Но приложение не даёт записаться на 23:30. Отмечу, что часы я выставил до 23:59, как позволил тот PR.

Вот он, поворот. Починка дала последние четырнадцать минут суток — и ничего не изменила. Встреча длится час; начавшись в 23:30, она кончится завтра, а завтра — это другая строка расписания.

Не хватало не пункта в списке. Не хватало того, чтобы день умел кончаться после полуночи.

Кадр пятый: 26 января 2023. Хотфикс

Питер Рихелен (PeerRich), сооснователь Cal.com, пишет в задачу две строки:

can you try again? we made a hotfix

попробуйте ещё раз, мы выкатили хотфикс

В тот же день приходит ответ со скриншотом:

Unfortunately, it still doesn't work for me. Not seeing any 11:30 PM option

К сожалению, у меня по-прежнему не работает. Пункта 23:30 не вижу.

Задача закрыта и в тот же день открыта заново.

Кадр шестой: 22 мая 2023. Честная строка

Ещё через четыре месяца тот же PeerRich пишет фразу, ради которой стоит читать чужие задачи:

this is a really low hanging plus hard to fix issue. We're reworking the entire booking page right now and will revisit this later

это одновременно и мелочь, и трудно чинится. Мы сейчас переделываем всю страницу записи и вернёмся к этому позже.

«Мелочь, которую трудно починить» — вот диагноз, и он поставлен верно. Снаружи это один пропущенный пункт в списке. Внутри — предположение, на котором стоит вся выборка слотов: доступность лежит внутри суток.

Кадр седьмой: 23 ноября 2023. Not planned

Через десять месяцев после открытия и через год после первой жалобы задача закрывается — со статусом not_planned.

Не «починено», не «дубликат». Не будем.

Кадр восьмой: сегодня

В коде Cal.com в сентябре 2026-го список времён по-прежнему собирается так — ScheduleComponent.tsx, строки 574–590:

js
1const end = dayjs().utc().endOf("day");
2for (
3 let t = dayjs().utc().startOf("day");
4 t.isBefore(end);
5 t = t.add(INCREMENT + (!t.add(INCREMENT).isSame(t, "day") ? -1 : 0), "minutes")
6) { … }
7// allow 23:59
8options.push({ value: end.toDate().valueOf(), … });

Тот самый -1: если очередные пятнадцать минут перепрыгнут в следующий день, отнять минуту, чтобы попасть в 23:59. И отдельный push под комментарием // allow 23:59 на строке 588 — пункт, который циклом не получается.

Ниже, у кнопки «добавить промежуток», ещё два шрама — строки 630–643:

js
1const nextRangeEnd =
2 nextRangeStart.hour() === 23
3 ? dayjs(nextRangeStart).add(59, "minutes").add(59, "seconds").add(999, "milliseconds")
4 : dayjs(nextRangeStart).add(1, "hour");
5
6end: nextRangeEnd.isAfter(endOfDay) ? endOfDay.toDate() : nextRangeEnd.toDate(),

Двадцать третий час — особый случай; всё, что вылезает за конец суток, подрезается по конец суток. Три года спустя граница на месте, и видно её именно по заплаткам вокруг.

Ссылки закреплены по коммиту 176037d0 — чтобы через месяц они показывали тот же код, а не чужой на том же номере. Репозиторий переименован в calcom/cal.diy; старые ссылки на calcom/cal.com перенаправляются туда же.

Цена решения

«Сутки — отрезок от 00:00 до 23:59» — это не строчка, это решение о модели. Заметно оно становится в тот день, когда приходит первый человек, работающий по ночам, и к этому времени на предположении уже стоят выборка слотов, часовые пояса и календарная синхронизация.

Дальше каждая заплатка честно возвращает по кусочку — четырнадцать минут, особый двадцать третий час, подрезка по полуночи — и ни одна не двигает границу.

Отсюда и «мелочь, которую трудно починить»: мелочь — снаружи, трудно — внутри.

Что с этим делать у себя

1
Найти, где сутки считаются закрытым отрезком

Ищите сравнение вида now >= open && now < close над временем в минутах от полуночи — и всё, что берёт endOf('day') как границу диапазона.

Why: Пока часы внутри суток, такое условие работает и выглядит правильным. Оно ломается ровно в тот день, когда кто-то заводит смену через полночь, — и ломается молча.
Check
  • Найдено условие или его отсутствие подтверждено
  • Проверено, что даёт `open=11:00, close=02:00`: должно получиться «закрыто круглые сутки»
2
Проверить, что говорит интерфейс, а не только что считает функция

Смотрите не на возвращаемое значение, а на строку, которую увидит человек. Поиск ближайшего открытия может пропустить сегодняшний день как прошедший и уверенно сказать «Закрыто · откроется в 11:00», пока заведение работает.

Why: Неверный ответ, произнесённый уверенно, дороже ошибки, которую видно. Гость не станет проверять.
Check
  • Прогнан сценарий «часы 11:00–02:00, сейчас 12:00» и записано, что показано человеку
3
Решить: поддерживаем ночную смену или отказываем честно

Третьего не дано. Либо день перестаёт быть закрытым отрезком в расчёте, либо такие часы не сохраняются и человек читает почему. Заплатка на границе суток — не решение: она возвращает минуты, а не сутки.

Why: История Cal.com — про то, чем кончаются заплатки: каждая честно даёт по кусочку, граница остаётся на месте, разговор длится год и закрывается словами not planned.
A person is needed here: Есть ли у заведения смена через полночь сейчас или в планах? От этого зависит, что чинить — расчёт дня или форму. answer from experience
Check
  • Решение записано в коде рядом с правилом, а не только в голове
4
Если отказываем — проверить, что запрет стоит не только на записи

Значение могло попасть в базу мимо формы: сидом, миграцией, прямым SQL или до появления запрета. Такой день живёт дальше и молчит — витрина показывает «закрыто», владелец узнаёт от гостя.

Why: Запрет на записи ловит только того, кто сегодня нажал «сохранить». Это ровно та же тишина, от которой запрет и защищал, только с другой стороны.
Check
  • Уже сохранённое нарушение видно в форме до всякого сохранения
  • Сказано ПОСЛЕДСТВИЕ («витрина посчитает этот день закрытым»), а не правило: правило видно в самих полях
5
Не делать схему чтения строгой заодно с записью

Соблазн причесать две схемы в одну велик, и выбор тут между двумя поломками. Строгое чтение роняет страницу настроек — ту самую, на которой это значение правят.

Why: Установка, где такие часы уже лежат, стала бы недоступной целиком, и починить её было бы неоткуда. Отказ на записи приходит в форму, где человек его прочтёт и исправит; отказ на чтении не приходит никуда.
Check
  • Асимметрия закрыта тестом с обеих сторон: запись отказывает, чтение принимает

Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода

v4Public 0 0
updated Sep 1, 2026

graphile-worker: очередь на Postgres, замеры в комментариях и настройка, не пережившая полутора лет

v2Public 0 0
updated Sep 1, 2026

Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег

v1Public 0 0
updated Sep 1, 2026

Saleor: три человека за три года просят денег без копеек, потому что таких монет нет в обращении

v1Public 0 0
updated Sep 1, 2026

Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда

v1Public 0 0
updated Sep 1, 2026

Medusa: кнопка «убрать скидку» выдавала максимальную, потому что ноль в JavaScript ложный

v1Public 0 0
updated Sep 1, 2026