Проблема появляется позже — когда пользователь хочет открыть дневник за прошлый месяц и увидеть не текущую версию задач, а то, как на самом деле выглядел тот день.
С этой задачей мы столкнулись при разработке Ritm — одного из проектов WebRuta.
На первый взгляд требование было простым:
В Дневнике пользователь должен видеть задачи, которые он планировал на конкретный день, какие из них выполнил, какие перенёс, а какие удалил.
Но здесь обнаружился конфликт двух разных моделей данных.
Операционный планировщик должен позволять задаче изменяться.
История, наоборот, не должна меняться вслед за ней.
Если этого не учесть на уровне архитектуры, прошлое пользователя начинает буквально переписываться.
В версии Ritm 3.0.2 мы вынесли историю задач в отдельный механизм ежедневных snapshots. Ниже — почему это понадобилось, как устроено решение и какие выводы из этого кейса применимы далеко не только к дневникам и планировщикам.
С чего началась проблема
Представим обычную задачу:
Подготовить отчёт
Дата: 12 сентября
Время: 10:00
Продолжительность: 45 минутВ простом планировщике она существует примерно как один объект:
Task
├── id
├── title
├── date
├── time
├── duration
├── done
└── ...Пользователь не успел выполнить её 12 сентября и нажал:
«Перенести на завтра».
Самое очевидное действие приложения:
date = 2026-09-13После этого текущий план действительно становится правильным.
Задача уже находится 13 сентября.
Но что произойдёт, когда через месяц пользователь откроет дневник за 12 сентября?
Если дневник просто делает выборку:
tasks.filter(task => task.date === "2026-09-12")задачи там больше нет.
Получается странная история:
12 сентября пользователь планировал подготовить отчёт.
Потом перенёс его.
После переноса приложение показывает, будто 12 сентября этой задачи никогда не существовало.
Для обычного task manager это иногда допустимо.
Для дневника — нет.
Операционные данные и исторические факты — разные вещи
Именно это оказалось ключевой мыслью.
Текущая задача отвечает на вопрос:
Что с этой задачей нужно делать сейчас?
История дня отвечает на другой:
Что происходило с этой задачей тогда?
Это разные модели.
Например, сегодня объект выглядит так:
Подготовить отчёт
Дата: 13 сентября
Статус: активноНо для 12 сентября должен сохраниться исторический факт:
Подготовить отчёт
Дата: 12 сентября
Статус: перенесеноОбе записи одновременно правильные.
Одна описывает настоящее состояние задачи.
Вторая — событие прошлого.
Если пытаться обслуживать оба сценария одним изменяемым объектом, рано или поздно появляются противоречия.
Почему просто хранить старую дату недостаточно
Можно было добавить к задаче:
previousDateНо это решает только один перенос.
Что будет после второго?
12 сентября
↓
13 сентября
↓
15 сентября
↓
18 сентябряПоявятся:
previousDate
previousPreviousDate
...Очевидно, это тупик.
Можно хранить массив:
task.history = [...]Так уже лучше.
Но появляется другая проблема.
Дневник организован по дням.
Чтобы открыть 12 сентября, приложению пришлось бы пройти по всем существующим задачам и искать внутри каждой события, относящиеся к этой дате.
При многолетнем использовании это становится неудобной моделью и для чтения, и для синхронизации.
Мы пошли в другую сторону:
история хранится рядом с самим днём, которому она принадлежит.
Как устроили историю в Ritm 3.0.2
У каждого дня workspace уже есть собственный объект:
Day
├── morning
├── evening
├── events
├── checkins
├── tags
└── ...В версии 3.0.2 к нему добавился:
taskHistory[]То есть структура концептуально стала такой:
Workspace
│
├── tasks[]
│
│ └── текущее состояние задач
│
└── days
│
├── 2026-09-12
│ └── taskHistory[]
│
├── 2026-09-13
│ └── taskHistory[]
│
└── ...tasks[] продолжает отвечать за оперативную работу Планировщика.
days[date].taskHistory[] отвечает за прошлое.
Так мы разделили две ответственности, не создавая вторую полноценную систему задач.
Что хранится в snapshot
Исторический снимок не является просто ссылкой на живую задачу.
В нём сохраняются данные, необходимые для восстановления контекста конкретного дня:
taskId
title
date
time
duration
actualMinutes
energy
area
main
done
status
completedAt
plannedAt
updatedAtЭто важный момент.
Если сохранить только:
taskIdа затем при открытии дневника брать остальные свойства из текущего объекта, проблема никуда не исчезнет.
Представим, что спустя неделю пользователь переименовал:
Подготовить отчёт
в:
Подготовить квартальный финансовый отчёт для руководителя
Если дневник обращается к live-объекту, прошлый день тоже внезапно получит новое название.
Историческая запись должна содержать собственное значение.
По сути snapshot отвечает:
Вот какой мы знали эту задачу применительно к этому дню.
Почему мы называем это snapshot, а не копией задачи
Полную копию live-объекта сохранять тоже не хотелось.
У задачи могут появляться технические свойства, не имеющие никакого значения для дневника.
Например, временное состояние интерфейса или поля будущих функций.
Если копировать объект целиком:
{ ...task }историческая модель начинает зависеть от эволюции основной модели.
Поэтому snapshot формируется явно.
Упрощённо:
{
id: task.id,
title: task.title,
date,
time: task.time,
duration: task.duration,
actualMinutes: task.actualMinutes,
energy: task.energy,
area: task.area,
main: task.main,
status
}Так мы сами определяем, что является частью исторического контекста.
Это полезное свойство и с точки зрения совместимости: добавление нового технического поля к Task не означает автоматическое изменение формата истории.
Пять состояний вместо одного done
Для обычного списка задач часто хватает:
done = true / falseНо исторической модели этого оказалось недостаточно.
В Ritm используются состояния:
planned
done
deferred
rescheduled
deletedПо-русски:
Не выполнено
Выполнено
Перенесено
Перенесено на другую дату
Удалено из планаРазница между ними важна.
Например, эти две ситуации не одинаковы:
Я запланировал задачу, но так и не сделал её.
и:
Я сознательно перенёс задачу на завтра.
Для обычной аналитики обе могут выглядеть как done = false.
Для дневника смысл разный.
То же касается удаления.
Удалённая задача не должна исчезать из истории.
Она должна остаться со статусом:
«Удалено из плана».
Самый важный порядок действий: сначала snapshot, потом mutation
Это одна из тех небольших деталей, от которых зависит вся модель.
Рассмотрим перенос задачи на завтра.
Неправильная последовательность:
1. Изменить дату задачи.
2. Попытаться сохранить историю старого дня.После первого шага часть контекста уже потеряна.
В Ritm сначала фиксируется состояние старого дня:
rememberTaskForDay(
task,
oldDate,
"deferred"
)И только после этого меняется live-задача:
task.date = nextDateПолучается:
Live task
12 сентября
│
│ snapshot: deferred
↓
История 12 сентября
после этого
Live task
13 сентябряЭто маленький пример общего правила:
если операция уничтожает или изменяет исторически значимую информацию, сначала нужно зафиксировать прошлое, а потом менять настоящее.
Перенос и изменение даты — не одно и то же
В интерфейсе Ritm существуют как минимум два похожих сценария.
Пользователь может нажать:
«Перенести на следующий день».
Тогда старый день получает:
deferredНо пользователь может просто открыть редактирование задачи и поменять:
12 сентября → 16 сентябряДля истории это другой смысл.
Поэтому старый день получает:
rescheduledПосле изменения новая дата снова получает актуальный snapshot:
plannedТо есть одна задача может одновременно оставить след:
12 сентября
Подготовить отчёт
Перенесено на другую датуа в текущем плане существовать как:
16 сентября
Подготовить отчёт
Не выполненоИ это именно то поведение, которого ожидает пользователь дневника.
Удаление — самый показательный сценарий
До появления истории операция выглядела бы просто:
tasks = tasks.filter(task => task.id !== id)После выполнения объекта больше нет.
Но если он существовал в плане прошедшего дня, физическое удаление из live-списка не должно уничтожать факт прошлого.
Поэтому перед удалением выполняется snapshot:
status = deletedИ только потом задача удаляется из tasks[].
С точки зрения пользовательского опыта получается:
В Плане
Задачи больше нет.
В Дневнике за тот день
Она остаётся:
○ Подготовить отчёт
Удалено из планаЭто кажется мелочью, пока не начать использовать приложение как дневник несколько месяцев.
Тогда именно такие детали определяют, можно ли вообще доверять собственной истории.
Что происходит с выполненными задачами
Отметка выполнения тоже обновляет snapshot.
Когда пользователь завершает задачу:
done = true
completedAt = ...история дня получает:
status = doneОдновременно сохраняется фактическое время, если пользователь использовал встроенную фокус-сессию.
Поэтому исторический блок способен показывать не только:
была такая задача;
но и:
10:00 · Работа · 45 мин · факт 52 мин
ВыполненоДневник постепенно становится не копией плана, а снимком того, как план соотнёсся с реальностью.
А если пользователь снова снимет отметку выполнения?
Здесь появляется интересное различие между журналом событий и snapshot-моделью.
Мы не строили полноценный event sourcing, где каждое нажатие хранится навсегда как отдельное событие:
10:01 planned
12:40 done
12:42 reopened
13:15 doneДля задачи Ritm это было бы избыточно.
Нам нужно было сохранить итоговый исторический смысл задачи относительно дня, а не каждое движение чекбокса.
Поэтому snapshot того же taskId внутри дня обновляется.
При этом сохраняется первоначальный plannedAt.
То есть модель immutable-like, а не абсолютно immutable:
- задача не исчезает из прошлого после переноса или удаления;
- её связь с историческим днём сохраняется;
- но актуальный результат внутри этого дня может обновляться.
Для дневника этого достаточно.
Почему мы не стали внедрять полноценный event sourcing
Теоретически задачу можно было представить исключительно как последовательность событий:
TaskCreated
TaskScheduled
TaskCompleted
TaskReopened
TaskDeferred
TaskRescheduled
TaskDeletedА текущее состояние восстанавливать путём проигрывания событий.
Это мощная архитектура.
Но мощность всегда имеет стоимость.
Нужно проектировать event store, версии событий, reducers, migrations и восстановление состояния. Усложняются синхронизация и отладка.
Для нашего требования основной вопрос был намного уже:
Как сохранить достоверный план конкретного дня, если live-задача позже изменится?
Ежедневный snapshot решал эту проблему значительно проще.
Это важный архитектурный урок, который мы стараемся применять и в других проектах:
не нужно выбирать максимально универсальную архитектуру. Нужно выбирать минимальную архитектуру, которая надёжно решает реальную задачу и оставляет понятный путь развития.
Но snapshot нельзя создавать бездумно
После добавления истории появилась ещё одна проблема.
Представим задачу на следующий месяц.
Пользователь просто изменил её время.
Если при каждом редактировании автоматически создавать:
days["2026-10-25"]дневник начнёт заполняться пустыми будущими днями, хотя человек ещё даже не прожил их.
Поэтому в rememberTaskForDay() есть отдельная защита.
Для будущей даты историческая запись автоматически не создаётся, если сам объект дня ещё не существует.
Исключение — ситуации, где сохранение явно запрошено через force.
Получается принцип:
Текущий/прошлый день
→ snapshot допустим
Будущий день без дневника
→ не создаём исторический день только из-за редактирования задачиЭто небольшая оптимизация, но она не позволяет техническому механизму истории загрязнять пользовательские данные.
Сохранение Дневника фиксирует весь план дня
Мы не полагаемся только на события редактирования.
Когда пользователь сохраняет Дневник, система дополнительно проходит по задачам выбранной даты и фиксирует их в taskHistory.
Условно:
Сохранить день
↓
rememberTasksForDay(date)
↓
зафиксировать задачи
↓
сформировать summary дняЭто даёт дополнительную точку стабилизации истории.
При сохранении рассчитываются:
сколько задач было запланировано;
сколько выполнено;
сколько минут планировалось;
сколько потрачено фактически;
какое дело было главным;
было ли оно выполнено.План и дневник перестают существовать как две независимые страницы.
Они становятся двумя представлениями одного прожитого дня.
Как читается история
Отдельная функция taskHistoryForDay() собирает представление конкретного дня.
Здесь тоже есть интересный момент.
Она сначала читает сохранённые snapshots:
day.taskHistoryа затем добавляет актуальные live-задачи, которые до сих пор действительно относятся к этой дате.
Получается объединение:
Persisted historical snapshot
+
Current tasks still on this date
↓
History of the dayЗачем это нужно?
Представим сегодняшнюю задачу, которая ещё существует только в tasks[] и пока не успела попасть в сохранённую историю.
Она всё равно должна отображаться в Дневнике.
Но если задача уже была перенесена, сохранённый snapshot остаётся единственным источником для старой даты.
Один taskId — одна запись в истории дня
При чтении используется сопоставление по идентификатору задачи.
Это не позволяет создавать несколько копий одного объекта в одном дне при каждом изменении.
Условно:
Map<taskId, snapshot>Если snapshot уже существует, он обновляется.
Если нет — добавляется.
Так можно несколько раз изменять время или состояние задачи, не превращая исторический план в журнал технических операций.
Это ещё одно различие между:
историей пользовательского дня
и:
audit log приложения.
Для audit log нам действительно понадобились бы отдельные события каждого изменения.
Но пользовательскому дневнику нужен итоговый контекст.
Что делать со старыми данными
Это была отдельная задача версии 3.0.2.
До появления taskHistory прежние версии Ritm уже могли хранить дневники пользователей.
Но в старых днях существовала только агрегированная информация:
задач запланировано: 5
выполнено: 3Названия конкретных задач не сохранялись.
После обновления у нас было два варианта.
Вариант 1. Попытаться реконструировать прошлое
Например, посмотреть текущие задачи и угадать, какие из них когда-то относились к старой дате.
Мы отказались от этого подхода.
Если достоверных данных нет, интерфейс не должен создавать красивую выдумку.
Вариант 2. Честно показать то, что известно
Именно так теперь работает Дневник.
Для старого дня он может показать:
Подробный список задач за этот день ещё не сохранялся.
И ниже:
В прежней версии сохранился итог:
3/5 выполнено.То есть новая версия не притворяется, будто знает историю, которую предыдущая версия никогда не сохраняла.
Для новых дней уже используется полноценный snapshot.
Это, на наш взгляд, значительно лучше искусственной «миграции», создающей недостоверные данные.
Для этой функции не понадобилась SQL-миграция
Ещё одно практическое решение версии 3.0.2: taskHistory удалось добавить внутрь уже существующего workspace/day payload.
Поэтому отдельная миграция PostgreSQL для этой функции не потребовалась.
При загрузке workspace нормализатор проверяет исторические записи:
- корректность ID;
- дату;
- продолжительность;
- фактическое время;
- энергию;
- область;
- допустимый статус;
- временные метки.
История дня также имеет ограничение по количеству элементов.
Это защищает приложение от повреждённых или аномально больших payload.
С точки зрения релиза изменение оказалось обратно совместимым с существующим форматом хранения.
Почему нормализация важна даже для собственных данных
Иногда кажется:
Это же наша база. Зачем проверять данные после чтения?
Но приложение живёт долго.
Форматы меняются.
Пользователь может восстановить старый backup.
Данные могут прийти через sync.
В браузере могла несколько месяцев храниться предыдущая версия workspace.
Поэтому новая функция не должна предполагать:
taskHistory всегда идеально сформирован текущим кодомПри загрузке записи приводятся к ожидаемой форме.
Например, неизвестный status не ломает интерфейс, а преобразуется в безопасное состояние:
done → done
иначе → plannedЭто особенно важно для долгоживущих пользовательских данных: приложение должно уметь переживать собственную эволюцию.
Как выглядит история для пользователя
В Дневнике появился отдельный блок:
«План этого дня».
Для каждой задачи отображаются:
- название;
- время;
- сфера;
- плановая продолжительность;
- фактическое время;
- признак главной задачи;
- исторический статус.
Например:
→ Подготовить отчёт
10:00 · Работа · 45 мин
Перенесеноили:
✓ Позвонить клиенту
14:00 · Работа · 15 мин
Выполненоили:
× Купить билеты
Личное · 20 мин
Удалено из планаЭто маленькая UI-деталь, но она полностью меняет смысл Дневника.
Раньше пользователь мог помнить:
Кажется, я что-то планировал в этот день.
Теперь приложение способно показать это само.
Месячная история тоже строится на тех же данных
После появления snapshots стало возможно показать в календаре Дневника:
дни с записями;
количество задач;
сколько выполнено;
процент выполнения за месяц.Причём статистика прошлого больше не зависит от того, куда пользователь впоследствии перенёс задачи.
Это принципиально.
Без snapshot-модели ежемесячная статистика могла постепенно изменяться задним числом.
Условно:
12 сентября:
3 из 5 задачПосле переноса двух невыполненных задач:
12 сентября:
3 из 3Получалось бы, что прошлый день со временем становится «успешнее» просто потому, что незавершённые задачи покинули дату.
Это уже не просто UI-баг.
Это искажение аналитики.
Исторические данные нельзя считать обычным кешем
На первый взгляд taskHistory похож на кеш live-задач.
Но есть принципиальное отличие.
Кеш можно удалить и построить заново из источника истины.
Историю перенесённой или удалённой задачи восстановить из текущих tasks[] уже нельзя.
После удаления:
live task = отсутствуетНо:
historical snapshot = уникальный фактПоэтому taskHistory является уже частью пользовательских данных, а не оптимизацией производительности.
Отсюда следуют требования к backup, sync и нормализации.
Если потерять этот массив, восстановить его автоматически невозможно.
Перенос адаптивным планировщиком тоже должен оставлять историю
В Ritm есть адаптивный план.
Он может предложить перенести часть нагрузки на следующий день, если текущий план слишком тяжёлый.
Здесь легко было бы допустить интересную ошибку.
Обычный ручной перенос сохраняет snapshot.
Но автоматический механизм мог бы просто изменить даты напрямую.
Тогда история зависела бы от способа, которым задача была перенесена.
Мы связали adaptive-plan с тем же механизмом captureTaskHistory.
Перед применением автоматического переноса каждая соответствующая задача получает:
status = deferredИ только после этого меняется план.
Это ещё один важный вывод:
бизнес-инвариант должен находиться ниже конкретного UI-сценария.
Если правило звучит:
перенос не должен уничтожать историю,
оно должно работать и для кнопки пользователя, и для автоматического планировщика.
Какие сценарии мы закрепили тестом
После реализации мы добавили отдельную regression-проверку План + Дневник.
Один из сценариев специально воспроизводит проблему.
Создаётся задача:
Подготовить отчёт
Дата: вчераСохраняется:
plannedЗатем:
deferredПосле этого live-задаче назначается сегодняшняя дата.
Тест проверяет:
история вчерашнего дня содержит задачу;
статус = deferred;
название сохранилось.Отдельно проверяется выполненная задача.
Затем workspace проходит через нормализацию, после чего тест убеждается, что taskHistory не исчез.
И наконец рендерится сам Дневник:
План этого дня
Перенесено
Подготовить отчётТо есть тест проходит всю важную цепочку:
domain model
↓
historical storage
↓
normalization
↓
viewДля нас это важнее unit-теста одной функции.
Проблема изначально существовала именно на пересечении нескольких слоёв.
Что оказалось самым важным в этом кейсе
Сначала задача формулировалась как UX-проблема:
В истории Дневника нужно показывать задачи прошлого дня.
Но решение оказалось архитектурным.
Если данные прошлого выводятся из текущего состояния изменяемых объектов, история неизбежно начинает дрейфовать.
Поэтому мы разделили:
операционный объект
и
историческое представлениеLive-задача может:
перемещаться;
редактироваться;
выполняться;
возвращаться в работу;
удаляться.Snapshot дня должен отвечать только за одно:
Что происходило с этой задачей относительно конкретного дня?
Где ещё встречается такая проблема
Этот паттерн намного шире ежедневника.
CRM
Сегодня сделка стоит:
800 000 ₽Но месяц назад клиент согласовывал:
650 000 ₽Если хранить только текущую сумму, история переговоров теряется.
Заказы
Адрес доставки клиента изменился.
Но старый заказ должен продолжать показывать адрес, на который он фактически оформлялся.
Каталог товаров
Товар сегодня стоит 5000 ₽.
Заказ прошлого года должен показывать цену покупки, а не текущую цену каталога.
Проекты
Сотрудник сменил должность.
История согласования должна сохранить, в какой роли он принимал решение в тот момент.
Документы
Текущая версия договора не должна заменять согласованную версию прошлого месяца.
Во всех этих случаях фундаментальный конфликт один:
текущее состояние объекта меняется, исторический факт — нет.
Snapshot, versioning, audit log или event sourcing?
После этого кейса нам стало полезно разделять четыре похожих механизма.
Snapshot
Отвечает:
Как выглядел объект в конкретном контексте?
Именно это используется в истории задач Ritm.
Versioning
Отвечает:
Какие версии объекта существовали?
Хорошо подходит, например, для документов.
Audit log
Отвечает:
Кто, когда и что изменил?
Нужен для административных и бизнес-критичных действий.
Event sourcing
Отвечает:
Из какой последовательности событий возникло текущее состояние?
Это уже более фундаментальная архитектурная модель.
Использовать самый мощный вариант в каждой задаче не нужно.
В нашем случае snapshot оказался достаточным.
Ещё один вывод: не всё прошлое можно восстановить задним числом
Это особенно заметно при развитии работающего продукта.
Если версия 2.x никогда не сохраняла названия задач дня, версия 3.x не сможет достоверно восстановить их чудесным образом.
Можно:
- сохранить существующие агрегаты;
- начать собирать подробную историю с новой версии;
- явно объяснить это пользователю.
Но нельзя незаметно сгенерировать недостающие факты и назвать их историей.
Для систем, которые работают с личными или бизнес-данными, честная неполнота лучше красивой недостоверности.
Что бы мы заложили сразу, если бы проектировали такую систему заново
Теперь модель была бы разделена уже на этапе проектирования.
Примерно так:
Task
↓
текущее состояние
TaskDaySnapshot
↓
состояние в контексте дняПри любой операции, способной разрушить исторический контекст:
defer
reschedule
delete
completeсначала выполняется сохранение snapshot.
А уже затем mutation live-объекта.
Этот порядок мы бы рассматривали не как деталь конкретной кнопки, а как инвариант доменной модели.
Главный урок
Разрабатывая историю, легко смотреть на неё как на экран.
Добавим календарь.
Добавим прошлые дни.
Покажем задачи.
Но экран — последняя часть задачи.
Сначала нужно ответить:
из каких данных вообще можно достоверно восстановить прошлое?
В нашем случае первоначальная модель задач умела хорошо отвечать на вопрос:
Что пользователь должен делать сейчас?
Но плохо отвечала:
Что пользователь планировал тогда?
Версия 3.0.2 разделила эти задачи.
tasks[] продолжает жить и изменяться.
taskHistory[] сохраняет контекст дня.
После этого перенос или удаление больше не переписывают историю.
И, пожалуй, это главный вывод из всего кейса:
если продукт должен помнить прошлое, нельзя строить это прошлое только из объектов, которым разрешено свободно меняться в настоящем.