Привет, Хабр! Меня зовут Макс Панфилов, я предприниматель и развиваю свой таск‑трекер внутри Telegram: задачи, напоминания, календарь и работа в группах. Пользоваться можно через бота с ИИ, мини‑апп и веб‑версию с десктопа. Мы запустились в 2023 году, и на январь 2026 у продукта 23 тысячи регистраций и больше 5 тысяч групп, куда добавили бота.
На старте я отложил фичу повторяющихся задач как трудоемкую. Но на масштабе в десятки тысяч пользователей возможность создавать регулярные чек-листы, отчеты, платежи стала базовой потребностью.
Реализовать её можно двумя путями: взять стандарт RRULE или написать своё. RRULE позволяет максимально гибко задавать логику повторений («каждую третью пятницу, кроме полнолуния»), но я посчитал его избыточным.
Во-первых, большинству хватает «каждый день» и «каждую неделю», а универсальность уровня календарных стандартов нужна редко. Опять же, сам Telegram в своей фиче дал выбор из очень ограниченного набора вариантов регулярности.
Во-вторых, в проекте уже есть уведомления через Agenda, завязанные на реальные документы в MongoDB, и аналитика, которая считает завершенные задачи — то есть «виртуальные события» потребовали бы переработки всех этих интеграций.
В-третьих, я хотел простой UX без конструктора правил.
Поэтому я принял волевое решение: никакого RRULE в первой версии фичи повторяющихся задач. Делаем фиксированный набор интервалов (день, неделя, месяц, год) и смотрим, как это живет.
Архитектура: Мастер и его Тени
Самый первый архитектурный вопрос: как хранить повторяющиеся задачи?
На старте у меня было два пути. Первый — хранить только «правило серии» и генерировать события на лету при чтении календаря. Второй — честно материализовать будущие события в базе.
Я выбрал гибридный подход — буферизированная материализация.
Коротко про стек проекта: NestJS, MongoDB, планировщик задач Agenda. Клиенты: Telegram Bot (Telegraf), Mini App и веб-версия на Vue 3.
Схема данных
Я не стал создавать отдельную коллекцию для «шаблонов» повторяющихся событий с правилами серии. Всё живет в одной коллекции events (у меня это коллекция с задачами). Это упрощает выборки, поиск и аналитику.
Появилось понятие Мастер-события и Инстанса (экземпляра).
Мастер-событие — это задача, которая хранит настройки серии: флаг isRecurring: true, интервал recurrenceInterval, таймзону recurrenceTimezone и массив исключённых дат excludedDates.
Инстансы — это обычные задачи, которые «принадлежат» мастеру через parentId. У них isRecurring: false, и при создании они копируют все остальные поля от мастера (текст, длительность, напоминание), но поставлены на свою конкретную дату из серии.
Вот как это выглядит в MongoDB-схеме (Mongoose):
@Prop({ type: Types.ObjectId, ref: 'Event' })
parentId?: Event; // ссылка на мастер для инстансов
@Prop({ type: Boolean, default: false })
isRecurring: boolean; // флаг мастера серии
@Prop({ type: String, enum: RecurrenceInterval })
recurrenceInterval?: RecurrenceInterval; // интервал регулярности повторения
@Prop({ type: String })
recurrenceTimezone?: string; // IANA-таймзона для генерации
@Prop({ type: [Date] })
excludedDates?: Date[]; // исключённые даты, если какое-то событие из серии удалено или точечно изменено – чтобы не создавался дубльА концептуально структура выглядит так:
events
├─ master (обычный event)
│ ├─ _id = M
│ ├─ isRecurring = true
│ ├─ recurrenceInterval = weekly
│ ├─ recurrenceTimezone = Europe/Moscow
│ └─ excludedDates = [ ... ]
├─ instance #1 (обычный event)
│ ├─ _id = I1
│ ├─ parentId = M
│ └─ startTime = 2025-01-17 09:00
└─ instance #2
├─ _id = I2
├─ parentId = M
└─ startTime = 2025-01-24 09:00
В моей модели мастер — это тоже задача, не отдельная скрытая сущность.
В UI это удобно воспринимать так: мастер виден в списке/календаре как обычная задача, но с признаком «серия». Инстансы выглядят как обычные задачи, но с «родством» к мастеру.

Механика: как рождаются инстансы
Есть особенность создания повторяющихся задач, из-за того, что я прикручивал функциональность к уже существующей архитектуре. У меня в интерфейсе так устроено, что в любом случае всегда создётся обычная задача, и только потом её можно сделать повторяющейся.
Технически процесс создания серии выглядит так:
Мастер-задача. Когда пользователь включает повторение, исходная задача помечается как «Мастер» (
isRecurring: true). Она запоминает интервал и, что важно, текущую таймзону из настроек пользователя (recurrenceTimezone).Калькулятор дат. Сервис-калькулятор берет
startTimeи проецирует его в будущее. Здесь я используюdayjsс поддержкой часовых поясов. Это важно: мы храним даты в UTC, но для расчета «завтрашнего утра» нужно знать, в какой таймзоне живет пользователь (особенно если где-то есть переход на летнее время).Пример реализации доменного сервиса:
calculateOccurrencesInRange(startDate, interval, rangeStart, rangeEnd, timezone) { const dates = []; // Важно: работаем в таймзоне пользователя let current = dayjs(startDate).tz(timezone); // 1. Проматываем до начала диапазона while (current.isBefore(rangeStart)) { current = this.addInterval(current, interval); } // 2. Собираем даты внутри окна while (current.isBefore(rangeEnd) || current.isSame(rangeEnd, 'day')) { dates.push(current.toDate()); current = this.addInterval(current, interval); } return dates; }Материализация. Для каждой расчетной даты в БД создается отдельный документ-инстанс. Он получает
parentId, указывающий на мастера.Наследование свойств. Инстанс копирует заголовок и длительность мастера. Напоминание (
timer) наследуется не как фиксированная дата, а как относительное смещение (например, «за 15 минут до начала»). Это позволяет системе вычислять точное время уведомления для каждого инстанса индивидуально:instance.startTimeминус 15 минут.
Почему я решил создавать реальные документы в БД, а не считать их на лету?
Система уведомлений. Планировщику (Agenda) нужен конкретный документ с ID, чтобы знать, когда отправить пуш. Виртуальные задачи потребовали бы написания прослойки, имитирующей БД.
Существующая экосистема. Веб, бот и мини-апп уже умеют работать с документами из Mongo. Материализация позволила почти не переписывать фронтенд и логику отображения — клиенты просто начали получать больше задач.
Проблема бесконечности
Если задача повторяется «каждый день», она бесконечна. Создать бесконечное число документов нельзя. Я ввел ограничение: горизонт планирования.
Горизонт — это мягкий ограничитель, в рамках которого происходит первичная генерация повторяющихся задач.
daily,weekly,biweekly→ 90 дней (~3 месяца)monthly,quarterly,semiannual→ 365 дней (~1 год)yearly→ 1095 дней (~3 года)
Почему так:
Это компромисс между ценностью (пользователь видит будущее) и нагрузкой (документы в БД + jobs в планировщике).
90 дней для
daily— 90 инстансов на серию. 365 дней — уже 365, и это резко повышает шум в БД и планировщике.Для
weeklyпри 90 днях это около 13 инстансов — почти бесплатно.
Для прикидки масштаба: если у вас 2 000 активных ежедневных серий, горизонт 90 дней — это порядка 180 000 инстансов «в буфере». Это уже ощутимо, но управляемо. 365 дней превратили бы это примерно в 730 000.
Технически горизонт — это обычные константы/настройки, который я вынес в конфиг на стороне бэкенда.
Вот как выглядит код начальной генерации инстансов с учетом горизонта:
// Начальная генерация инстансов на горизонт, соответствующий интервалу
const masterStart = new Date(master.startTime);
const horizonDays = this.getMaxHorizonDays(dto.recurrenceInterval);
const rangeStart = this.addDays(masterStart, 1);
const rangeEnd = this.addDays(masterStart, horizonDays);
const dates = this.calculator.calculateOccurrencesInRange(
masterStart,
dto.recurrenceInterval,
rangeStart,
rangeEnd,
recurrenceTimezone
);
if (dates.length) {
const createdInstances = await this.recurringRepo.createInstances(
master,
dates
);
// Планируем напоминания для созданных инстансов
for (const inst of createdInstances) {
await this.scheduler.scheduleIfNeeded(ownerId, inst, {
sourceTag: 'v4:recurring:init',
});
}
}
Стратегия генерации и «вечный» календарь
Чтобы совместить бесконечность серии с конечностью ресурсов БД, я использую гибридный подход из двух механизмов.
1. Фоновое «Скользящее окно» (Sliding Window)
В фоне работает джоба (Agenda), которая раз в час проверяет активные серии и «достраивает» инстансы на горизонт вперед (например, поддерживает постоянный буфер в 90 дней). Это гарантирует, что у пользователя всегда есть задачи на ближайшее будущее для списков и уведомлений.
Для изоляции нагрузки я поднял отдельный инстанс Agenda на отдельной коллекции MongoDB (eventsRecurring):
// infrastructure/adapters/recurring-buffer-agenda.adapter.ts
this.agenda = new Agenda({
db: { address: dbUrl, collection: 'eventsRecurring' },
processEvery: '5 minutes',
});
// Раз в час расширяем буфер
await this.agenda.every('0 * * * *', 'events-recurring-buffer');
2. Г��нерация по требованию (On-Demand)
Если пользователь открывает календарь на дату за пределами горизонта (например, через год), бэкенд генерирует инстансы для этого диапазона синхронно в момент запроса.
async execute(
userId: string,
rangeStart: Date,
rangeEnd: Date,
skipHorizonLimit = false // ключевой флаг!
): Promise<void>
Для фоновой генерации skipHorizonLimit = false (соблюдаем лимиты), для навигации фронта skipHorizonLimit = true (генерируем всё, что просит UI).
Ограничения списков
В списках «Все невыполненные» и в боте выводятся только материализованные задачи (из буфера). Это компромисс: невозможно (и не нужно) показывать бесконечный список задач на 10 лет вперед. Горизонта в 3-12 месяцев вполне достаточно для оперативного планирования.
Индексы (иначе всё умрёт на масштабе)
Как только вы материализуете инстансы, ваши основные запросы превращаются в «найди все инстансы по parentId» и «найди все мастера пользователя». Без индексов Mongo начнёт делать COLLSCAN и вы быстро упрётесь в нагрузку.
В моём случае я добавил индексы отдельной миграцией:
// db/migrations/*-add-recurring-fields.ts
await events.createIndex(
{ parentId: 1, uid: 1 },
{ sparse: true, name: 'idx_parentId_uid' }
);
await events.createIndex(
{ parentId: 1, startTime: 1 },
{ sparse: true, name: 'idx_parentId_startTime' }
);
await events.createIndex(
{ uid: 1, isRecurring: 1 },
{ sparse: true, name: 'idx_uid_isRecurring' }
);
Почему таймзоны важны (даже если у вас UTC в БД)
Может возникнуть вопрос: если мы храним все даты в UTC, зачем нам отдельное поле recurrenceTimezone? Разве нельзя просто прибавлять 24 часа к UTC-метке?
На самом деле — нельзя. В мире людей «каждый день в 09:00» означает «когда на моих настенных часах будет девять утра», а не «когда пройдет ровно 24 атомных часа».
Летнее и зимнее время (DST). Если вы живете в Лондоне, то летом 09:00 — это
08:00 UTC, а зимой —09:00 UTC. Если просто прибавлять фиксированное время к UTC-метке, то при смене сезона задача визуально «уедет» на час для пользователя.Политические изменения. Страны иногда меняют свои часовые пояса или отменяют перевод стрелок. Хранение имени зоны (например,
Europe/Moscow) позволяет системе автоматически подстроиться под новые правила после обновления базы таймзон на сервере, не переписывая данные в БД.
Поэтому мой калькулятор работает так: берет исходный UTC, переводит его в локальное время пользователя (используя recurrenceTimezone), прибавляет «1 день» (или месяц/год) именно в локальном контексте, и только потом конвертирует результат обратно в UTC. Таким образом, UTC-время генерации «плавает», чтобы пользовательское время оставалось стабильным.
Редактирование серии
Пользователь заходит в редактирование задачи-инстанса из серии и меняет ей название. Что должно произойти?
Поменяться только эта задача?
Поменяться эта и все будущие?
Поменяться вообще все, включая прошлые?
Я принял решение, что не буду давать пользователям возможность редактировать инстансы серии без изменения всей серии: по умолчанию форма редактирования инстанса отображается в режиме read-only. Над полями со значениями задачи отображаются кнопки с опциями:

Если пользователь хочет отредактировать всю серию, мы возвращаем его в интерфейсе к исходной задаче, где он может внести правки, которые распространятся на все существующие и будущие инстансы.
Если же пользователь хочет отредактировать только событие на этот день, мы «отцепляем» инстанс его от серии и работаем с ним как с обычной задачей: у инстанса обнуляется parentId, а в мастере в excludedDates запоминается исходная дата. Это нужно, чтобы при следующей догенерации серия не создала «призрака» на тот же день.
При этом есть одно важное исключение: отметить инстанс выполненным можно без необходимости отвязать его от серии.
Иначе получается странно: пользователь просто поставил галочку «готово», а система внезапно «отцепила» задачу от серии и добавила excludedDates. Поэтому в коде я явно различаю:
status-only изменение (например,
completed) — обычный update, без detachсмысловое редактирование (текст/время/таймер/теги и т.п.) — «открепляет» задачу от серии и делает его самостоятельной задачей
Вот упрощённая логика (из use-case редактирования повторений):
// application/events/edit-recurring-event.usecase.ts
const isStatusOnlyPatch = this.isStatusOnlyPatch(dto);
// Для инстанса серии: если патч только про статус — не «отцепляем» от серии
if (!editMode && isInstance && isStatusOnlyPatch) {
return this.updateUC.execute(userId, id, dto);
}Такое же поведение – для опции "Удалить это событие": открепляем – вносим дату в excludedDates – удаляем только эту открепленную задачу.
Кнопка "Остановить серию" останавливает генерацию будущих инстансов, но ведёт себя аккуратно с уже созданными задачами:
будущие инстансы серии удаляются (и перед этим отменяются их напоминания);
прошлые/текущие инстансы остаются в истории, но «отвязываются» от серии (
parentId = null) и становятся обычными задачами;бонус UX: остановку можно вызвать не только по мастеру, но и по конкретному инстансу — тогда этот инстанс становится «последним» в серии, а всё после него удаляется.
Уведомления и спам событиями
В моей архитектуре любое изменение задачи триггерит доменное событие TASK_UPDATED. На него подписаны сайд-эффекты: обновить кэш, пересчитать статистику, отправить пуш. Когда вы редактируете серию из 90 задач, эмиттить 90 событий — это выстрел в ногу. Система захлебнется.
Решение: Для массовых операций я реализовал отдельные методы, которые обновляют данные пакетно, в обход стандартной шины событий. Но как быть с напоминаниями? Ведь у каждой задачи свое время уведомления. Я сделал специальный сервисный метод, который после массового апдейта пробегается по затронутым ID и тихо перепланирует джобы в Agenda, не создавая лишнего шума в логах активности и аналитике.
Внедрение повторений затронуло и другие части системы. Например, у меня есть возможность добавить бота в группу в Telegram и через него ставить задачу на коллег. В контексте повторяющихся задач инстансы должны наследовать принадлежность к группе от мастера, но автоматическая генерация не должна превращаться в спам «создана новая задача» в общий чат.
Чего тут нет (и где правда нужен RRULE)
Чтобы не было иллюзий про «универсальную рекуррентность», перечислю, чего сознательно нет:
«По будням», «каждый второй вторник», «последняя пятница месяца».
«Каждые N дней» как произвольное N.
Завершение серии по достижению количества повторений или определенного дня окончания.
Богатый UI для исключений (кроме «изменить/удалить только это»).
Итог: почему велосипед поехал
Я получил работающую систему повторяющихся задач, которая закрывает 95% потребностей пользователей, не внедряя монструозный RRULE.

Всё работает отлично... И повторяющиеся задачи и весь новый и существующий функционал.. 👍
Игорь. Р, постоянный пользователь «OK, Bob!» (сообщение из чата коммьюнити юзеров)
Основные выводы, к которым я пришел в процессе реализации:
Отказ от RRULE оправдан для MVP. Большинству пользователей действительно хватает базовых интервалов (день, неделя, месяц). Сложные правила нужны редко, а сложность разработки они повышают кратно.
Материализация инстансов упрощает жизнь. Реальные документы в базе позволяют использовать существующие индексы, поиск, аналитику и механизмы уведомлений без написания сложного слоя абстракции для «виртуальных» событий.
Таймзоны критичны. Просто хранить UTC недостаточно. Чтобы «9 утра» оставались «9 утра» при смене сезонов (DST) или переезде, нужно учитывать локальное время пользователя при расчете следующей даты.
Горизонт планирования спасает базу. Генерация задач «скользящим окном» (на 3–12 месяцев вперед) держит размер коллекции под контролем, а бесконечный скролл в будущее решается генерацией on-demand.
UX — это искусство компромисса. Запрет на «частичное» редактирование инстанса (кроме статуса) без отвязки от серии избавил от адовых коллизий данных, при этом оставив сценарий понятным для пользователя.
Если вам предстоит делать повторяющиеся события — не бойтесь простых решений. Иногда date.add(1, 'day') (с учетом таймзон!) — это всё, что нужно для счастья.
Пишу про разработку с использованием LLM, управление IT-бизнесом и digital-продуктами в своём Telegram-канале. Там же делюсь цифрами и внутренней кухней развития «OK, Bob!» – присоединяйтесь, если было полезно.
