Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
36 KiB
Hado Core — дизайн ядра умных уведомлений
Дата: 2026-09-03. Статус: согласован в брейншторме, ждёт ревью.
1. Что это и зачем
Hado (波) — отдельный переиспользуемый сервис уведомлений для всех сервисов экосистемы (документы, будущие остальные). Клиенты присылают ядру события с датой, ядро владеет состоянием, само считает, когда и куда напоминать, и само доставляет по каналам, в которых пользователь сейчас доступен.
Принципы, которые нельзя нарушать:
- Ядро не знает доменов. Ни ДР, ни паспортов, ни седул. Только событие, дата,
готовый к показу
payload. Подключение нового клиента — токен в реестре, правок ядра ноль. - Клиенты пушат, ядро хранит. Как шина: клиент делает upsert, ничего у себя не хранит и не планирует. Ядро само разносит по каналам.
- Напоминание — когда ты доступен, не когда стрелка на девяти. Иначе это ещё одни «Напоминания» Apple, которые стираются на автомате.
- «Помню» и «Сделано» — разные вещи. Первое гасит текущее напоминание, второе закрывает событие. Бейдж падает только от второго.
2. Границы
В этой спеке: бэкенд ядра (Laravel + Postgres), API для клиентов, API для
пользователя, веб-инбокс, каналы telegram и webhook, доставка по присутствию.
Вне спеки (отдельные проекты, путь открыт):
- iOS-приложение: бейдж, виджет «Грядёт», Live Activity, канал
apns. - Разбор PDF через LLM — это клиент документов, не ядро.
- Голосовой ассистент — подключается как
webhook-канал через Home Assistant, ядру про него знать не нужно.
3. Понятия
- Источник (
source) — сервис-клиент с токеном.docs, потом другие. - Пользователь (
user) — логин изX-Remote-Userхаба sekai. У него свой часовой пояс, тихие часы и каналы. - Событие (
event) — то, что имеет срок. Идентичность:(source, source_ref, topic). Ядро само собирает ключ из этих трёх полей, клиент ничего не считает. - Срок (
due) — календарный день, опционально с моментом внутри дня. - Старт (
fire_on) — день, с которого начинается подготовка. Если подготовки нет, клиент шлётfire_at = due_at. - Каскад — точки подготовки между стартом и сроком, ядро строит их по формуле.
- Окно — интервал, внутри которого ядро ищет момент доставить напоминание.
- Присутствие — ответ канала на вопрос «пользователь сейчас тут?».
4. Модель данных
sources
id, name UNIQUE, token_hash, created_at
users
id, login UNIQUE, tz (IANA, напр. America/Montevideo),
quiet_start TIME (default 22:00), quiet_end TIME (default 09:00),
created_at, updated_at
events
id
source_id FK sources
user_id FK users
source_ref string -- "person:42"
topic string -- "birthday:2026", "payment:2026-10"; по умолчанию ""
due_mode enum(local, fixed)
-- local: срок календарный, живёт в поясе пользователя (ДР, седула)
-- fixed: срок — момент во времени, пояс зашит клиентом (вылет из Мадрида)
due_date date -- при local: день срока
due_time time -- при local: момент внутри дня, 24:00:00 = весь день
due_instant timestamptz -- при fixed: момент срока
-- tagged union: заполнены поля своего режима, код ветвится по due_mode
fire_on date -- старт подготовки; = день срока, если подготовки нет
after_due enum(keep, expire)
payload jsonb -- title, subtitle, deep_link, done_label
state enum(scheduled, preparing, today, overdue, done, expired, withdrawn)
quiet_until timestamptz -- «молчать до»; по умолчанию в прошлом
done_at timestamptz nullable
created_at, updated_at
UNIQUE (source_id, source_ref, topic)
channels
id, user_id FK, type enum(web, telegram, webhook), config jsonb, enabled bool,
created_at, updated_at
-- web.config: {} (создаётся автоматически, не удаляется)
-- telegram.config: { chat_id }
-- webhook.config: { deliver_url, presence_url }
deliveries
id, event_id FK, channel_id FK,
window_start timestamptz -- начало окна, в которое доставлено
action_token string UNIQUE -- для ссылок «Помню»/«Сделано» из канала
result enum(pending, ok, failed) -- pending ставится в момент диспатча
sent_at timestamptz nullable, error text nullable
meta jsonb -- служебное канала, напр. telegram message_id
UNIQUE (event_id, channel_id, window_start)
Все enum(...) в схеме — строковые колонки, значения фиксирует PHP-enum; так тесты гоняются на SQLite.
Два режима срока. local — дата календарная, без пояса; пояс берётся у
пользователя в момент вычисления. Переехал в другую страну, сменил tz — все окна
сдвинулись сами, пересчитывать нечего. fixed — момент абсолютный; «день срока»
и «момент внутри дня» для него вычисляются из due_instant в текущем tz
пользователя каждый раз, когда нужны. Вылет из Мадрида в 08:15 при
tz = America/Montevideo — это 03:15 по местному, и ядро считает именно так.
Всюду ниже «день срока» и «момент срока» означают результат этого вычисления
для обоих режимов.
fire_on в обоих режимах календарный: подготовка начинается с утра этого дня,
где бы пользователь ни был.
Все UNIQUE: events (source_id, source_ref, topic),
deliveries (event_id, channel_id, window_start) — второй защищает от двойной
доставки, см. §9.
Кэш присутствия живёт в cache-store (ключ presence:{channel_id}, TTL 60 с), в
таблицах его нет.
5. Контракт для клиентов
Авторизация: Authorization: Bearer <token источника>. Источник берётся из
токена, в URL и теле его нет.
Upsert пачкой
PUT /api/events
{
"events": [
{
"user": "nikita",
"source_ref": "person:42",
"topic": "birthday:2026",
"due_at": "2026-08-14", // или "2026-09-10T14:00"
"fire_at": "2026-08-14", // = due_at, если подготовки нет
"after_due": "expire",
"payload": {
"title": "Сегодня ДР — Вася",
"subtitle": "исполняется 34",
"deep_link": "/people/42",
"done_label": "Поздравил" // опционально, по умолчанию «Сделано»
}
}
]
}
→ 200 { "events": [ { "source_ref": "person:42", "topic": "birthday:2026", "id": 17, "state": "scheduled" } ] }
Правила разбора due_at, три формы:
| форма | пример | режим | что хранится |
|---|---|---|---|
| дата | 2026-08-14 |
local |
due_date, due_time = 24:00 (весь день) |
| дата-время без смещения | 2026-09-10T14:00 |
local |
due_date, due_time |
| дата-время со смещением | 2026-09-10T08:15+02:00 |
fixed |
due_instant |
Правило для клиента: смещение ставь только если событие привязано к месту (вылет, встреча по чужому времени). ДР, сроки документов, оплаты — без смещения, они едут за пользователем.
fire_at — только YYYY-MM-DD, не позже дня срока. Валидация: fire_at <= день
due_at, user непустой, payload.title непустой, до 500 событий в пачке. Одна
плохая запись → 422 на всю пачку с указанием индекса, ничего не применяется.
Правила upsert по существующему событию (та же тройка идентичности):
| что пришло | что делает ядро |
|---|---|
due_at тот же |
обновляет fire_on, after_due, payload, user. Если состояние терминальное (done/expired/withdrawn) — не трогает. Иначе пересчитывает состояние из дат (см. §6). |
due_at другой |
это перенос (рейс перенесли, срок документа продлили): обновляет всё, сбрасывает quiet_until, пересчитывает состояние из дат даже из терминального. |
Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с
тем же person:42 и новым topic — см. правило ниже.
Повторяющиеся события: каждое вхождение — отдельная строка. Клиент кладёт
период в topic: birthday:2026, birthday:2027, payment:2026-09,
payment:2026-10. Тогда вхождения независимы: сентябрьская аренда висит в
overdue и долбит, октябрьская спокойно создаётся рядом со своим каскадом,
следующий ДР можно запушить хоть за год — он лежит в scheduled и молчит.
Незакрытые keep-вхождения копятся, и это правильно: в этом смысл keep.
Почему нельзя переиспользовать одну строку под все годы. Ядро не отличает
«клиент перевёл ДР на следующий год» от «рейс перенесли в день вылета» — оба
выглядят как новый due_at и оба сбрасывают состояние. В само утро ДР клиент
прислал бы дату следующего года и стёр живое today. А для keep было бы ещё
хуже: пока сентябрь не закрыт, октябрь запушить нельзя, иначе оборвётся
просроченный долбёж, — и октябрьского напоминания не существует вообще.
Правило «due_at другой → сбросить состояние» остаётся для настоящих переносов:
рейс перенесли, срок документа продлили. Это то же событие с новой датой.
Терминальные строки (done, expired, withdrawn) ядро удаляет через
HADO_RETENTION_DAYS (по умолчанию 90) после перехода в терминальное состояние,
чтобы вхождения не копились вечно.
Снятие и чтение
DELETE /api/events?source_ref=person:42&topic=birthday:2026 → 204, state = withdrawn
GET /api/events?source_ref=person:42&topic=birthday:2026 → 200 { id, state, due_at, ... }
topic в запросе можно опустить — тогда пустая строка.
6. Жизненный цикл события
state — это и есть фаза. Хранится в базе, меняет крон (тик раз в минуту) по
календарю пользователя в его tz.
| state | смысл | напоминает | в бейдже |
|---|---|---|---|
scheduled |
день старта ещё не пришёл | нет | нет |
preparing |
идёт подготовка (fire_on <= сегодня < день срока) |
раз в день | нет |
today |
день срока, момент срока ещё не наступил | каждый час | да |
overdue |
момент срока прошёл, after_due = keep |
раз в день | да |
done |
нажал «Сделано» | нет | нет |
expired |
момент срока прошёл, after_due = expire |
нет | нет |
withdrawn |
клиент прислал DELETE | нет | нет |
Переходы:
scheduled → preparing: наступило 00:00 дняfire_on, иfire_onраньше дня срока.scheduled → today,preparing → today: наступил старт дня срока. Обычно это 00:00 дня срока. Но если момент срока раньше начала активных часов (вылет в 07:00 приquiet_end = 09:00), первое окно дня уже опоздало бы, и событие ушло вexpired, не пикнув ни разу. Поэтому для таких событий старт дня срока — за два часа до тихих часов накануне:quiet_start − 2hпредыдущего дня (20:00 приquiet_start = 22:00), то есть два последних часовых окна вечера. Вечер накануне работает какtoday: часовые окна, бейдж, всё как в день.today → overdue: наступил момент срока,after_due = keep.today → expired: наступил момент срока,after_due = expire.- любое из
scheduled / preparing / today / overdue→done: пользователь нажал «Сделано» (изscheduled— заранее, из инбокса). - любое →
withdrawn: DELETE от клиента.
Пересчёт состояния из дат (при upsert): берётся «сейчас» в tz пользователя и
применяются те же условия, что и переходы. Событие, пришедшее уже в день срока,
сразу становится today; пришедшее с fire_on в прошлом — preparing. Задним
числом ничего не доставляется.
Бейдж = число событий пользователя в today + overdue.
7. Каскад
Точки подготовки нужны только для preparing и только чтобы знать, до какого
дня молчать после «Помню». Не хранятся, считаются из fire_on и дня срока (due_day, вычисленного по режиму из §4):
d = due_day - fire_on // дней
points = []
while d >= 1:
points.append(due_day - d)
d = d div 2
fire_on за |
точки, дней до срока |
|---|---|
| 0 | нет точек, preparing не бывает |
| 1 | 1 |
| 7 | 7, 3, 1 |
| 30 | 30, 15, 7, 3, 1 |
| 60 | 60, 30, 15, 7, 3, 1 |
| 180 | 180, 90, 45, 22, 11, 5, 2, 1 |
8. Окна, «Помню», «Сделано»
Напоминание не шлётся в момент — ищется внутри окна. Окна лежат внутри
активных часов пользователя [quiet_end, quiet_start), по умолчанию 9:00–22:00
в его tz.
| state | окна | fallback |
|---|---|---|
preparing |
одно в день: 9:00–22:00 | за 30 мин до конца окна |
today |
каждый час: 9:00–10:00, 10:00–11:00, … 21:00–22:00 | только в трёх окнах дня: 9:00, 14:00, 20:00 (HADO_FALLBACK_HOURS), за 10 мин до их конца |
overdue |
одно в день: 9:00–22:00 | за 30 мин до конца окна |
Часовая частота в today — только для каналов, которые сказали present: ты
рядом, тебя можно дёрнуть, ты ответишь за минуту. Если никто не present,
стрелять наугад каждый час нельзя: это до 13 сообщений в день в Telegram, и
такой бот замьютят через неделю — ровно та петля, от которой уходим. Поэтому
fallback-доставка в today ограничена тремя окнами: утро, день, вечер.
Если момент срока в today наступает посреди окна, окно обрезается по нему, и
два следствия: fallback-запас считается от обрезанного конца (срок 9:30 →
fallback в 9:20, а не в 9:50 за краем), а само обрезанное окно всегда
fallback-окно, независимо от HADO_FALLBACK_HOURS: это последний шанс перед
сроком, его нельзя пропустить из-за того, что час не попал в список.
Событие получает не больше одной доставки на окно (deliveries.window_start),
и только если now >= quiet_until.
«Помню» (ack) — «молчи до следующей контрольной точки». Ставит quiet_until:
| state | quiet_until |
|---|---|
preparing |
00:00 ближайшей точки каскада после сегодня; если точек больше нет — старт дня срока (§6) |
today |
начало окна через одно (следующий час пропускаем) |
overdue |
00:00 послезавтра (завтрашнее окно пропускаем) |
Пример preparing: fire_on за 30 дней, напомнило на −30, −29, −28; «Помню» на
−28 → тишина до −15, там снова каждый день до следующего «Помню» → до −7.
Пример today: напомнило в 11:00, «Помню» → 12:00 молчит, 13:00 снова.
«Сделано» (done) — событие в done, done_at = now, напоминания и бейдж
снимаются. Работает в любой момент, хоть за два месяца до срока.
Действия доступны тремя путями: кнопки в веб-инбоксе (POST /me/events/{id}/ack|done,
под SSO), кнопки Telegram (callback ack:{token} / done:{token}) и подписанные
ссылки для webhook (POST /a/{token}/ack, POST /a/{token}/done). token — это
deliveries.action_token, живёт до терминального состояния события.
9. Доставка по присутствию
Каждый канал реализует один контракт:
interface Channel
{
/** present | absent | unknown */
public function presence(User $user, array $config): Presence;
public function deliver(Event $event, User $user, array $config, string $actionToken): DeliveryResult;
}
Как каналы отвечают на presence:
| канал | present |
иначе |
|---|---|---|
webhook (HA) |
GET presence_url вернул {"present": true} |
absent при false, unknown при ошибке/таймауте |
telegram |
пользователь жал кнопку или писал боту за последние 10 мин | unknown (бот не видит онлайн-статус) |
web (инбокс) |
последний POST /me/heartbeat был < 90 с назад |
absent |
Честно про реальность: присутствие по-настоящему работает у HA и веба. Telegram
почти всегда unknown (боту никто не пишет), поэтому он получает в основном
fallback-доставки. Это осознанно: Telegram — запасной канал, не основной.
Heartbeat веба шлётся только когда вкладка видима (visibilityState === 'visible') и было движение мыши или клавиатуры за последние 3 минуты.
Забытая вкладка в фоне на десктопе в другой комнате не должна отвечать
«пользователь тут», иначе она молча съест все реальные доставки в Telegram и HA.
web — обычный канал в таблице channels, создаётся автоматически вместе с
пользователем, удалить нельзя. Его deliver ничего не шлёт (инбокс и так
показывает всё), только пишет запись в deliveries: окно закрыто, пользователь
увидел в инбоксе. Зато присутствие он определяет полноценно: если открыта
вкладка, доставлять в Telegram незачем.
Алгоритм тика (раз в минуту) для каждого события в preparing/today/overdue,
находящегося в открытом окне, без строки в deliveries за это окно и с
now >= quiet_until:
- Спросить
presence()у всех включённых каналов пользователя и уweb. Параллельно, таймаут 2 с, ответ кэшируется на 60 с. - Есть
present→ доставить только в них. Окно закрыто. - Никого
present, окно не fallback-окно или до его конца больше запаса → ждать следующего тика. - Никого
present, fallback-окно, до конца меньше запаса → доставить во все включённые каналы. Лучше один раз наугад, чем пропустить окно.
Защита от двойной доставки. «Доставить» в тике означает: вставить строку
deliveries (event_id, channel_id, window_start, result = pending) в момент
диспатча, в той же транзакции, и только потом поставить job в очередь. Job
делает HTTP-вызов и апдейтит result / sent_at / error. Следующий тик видит
строку и событие в это окно больше не трогает, даже если HA отвечает две минуты.
UNIQUE (event_id, channel_id, window_start) — страховка на случай двух
параллельных тиков. Job с ретраями (3 попытки, экспоненциально), пока не
исчерпал — pending.
Почему тик раз в минуту, хотя окна часовые. Тик нужен не для окон, а для
присутствия: окно 9:00–22:00 «выстреливает» в ту минуту, когда ты пришёл домой
или открыл инбокс. При тике раз в 15 минут ты ждёшь до 15 минут после того, как
уже сел за комп. Тик дешёвый: один запрос «события в открытых окнах без доставки»,
presence кэшируется на минуту, если таких событий нет — тик ничего не делает.
Момент срока (today → expired в 14:00) тоже отслеживается с точностью до минуты.
У пользователя без каналов доставок нет, события видны только в инбоксе. Это нормальное состояние, не ошибка.
Что шлют каналы
telegram: одно сообщение title + subtitle + inline-кнопки «Помню» и
done_label. Повтор в следующем окне — новое сообщение. После done кнопки на
старых сообщениях убираются (edit reply markup, best effort).
webhook: POST deliver_url
{
"event": { "id": 17, "state": "today", "due_at": "2026-08-14", "source": "docs",
"title": "Сегодня ДР — Вася", "subtitle": "исполняется 34",
"deep_link": "/people/42", "done_label": "Поздравил" },
"actions": { "ack": "https://hado.../a/<token>/ack", "done": "https://hado.../a/<token>/done" }
}
10. Пользователь: пояс, тихие часы, инбокс
GET /me → { login, tz, quiet_start, quiet_end, badge }
PATCH /me { tz?, quiet_start?, quiet_end? }
GET /me/events?state=... → список (по умолчанию все нетерминальные)
POST /me/events/{id}/ack
POST /me/events/{id}/done
POST /me/heartbeat
GET /me/channels
POST /me/channels { type: webhook, config: {deliver_url, presence_url} }
DELETE /me/channels/{id}
POST /me/channels/telegram/link → { code, bot_url } // затем /start <code> боту
Пользователь создаётся при первом запросе с новым X-Remote-User, tz из
HADO_DEFAULT_TZ. Смена tz — руками через PATCH /me или из HA; позже iOS будет
обновлять сама.
Два дополнения по дизайн-хендоффу от 2026-09-04 (docs/design/handoff/):
POST /me/events { title, subtitle?, due_date, due_time? }
→ ручное событие. Источник `manual` зарезервирован, токена у него нет,
через /api он недоступен. fire_at = срок − 30 дней, after_due = keep,
done_label «Сделано». Так ручное событие само попадает в «Грядёт»
за месяц, до этого лежит в «Запланировано».
GET /me/archive → закрытые события (done, expired), новые сверху,
до 100 штук, с подписью закрытия: done_label или «Истекло».
withdrawn в архиве не показывается. Глубина архива = retention.
Веб-инбокс — одна Blade-страница под SSO по дизайну Sekai (dark only): список
событий по группам today, overdue, preparing, scheduled, у каждого кнопки
«Помню» / «Сделано», deep_link ведёт в клиент; форма «+ Добавить» для ручного
события; сворачиваемый архив; блок настроек: пояс, тихие часы, каналы, привязка
Telegram. Страница держит heartbeat. Никакого SPA.
11. Доступ
Три входа, три механизма:
| вход | маршруты | авторизация |
|---|---|---|
| клиенты | /api/* |
Bearer-токен источника; php artisan hado:source:create <name> печатает токен один раз, в базе хэш |
| пользователь | /, /me/* |
sekai forward_auth, ядро доверяет X-Remote-User (сеть изолирована, как в спеке sekai) |
| действия из каналов | /a/{token}/*, /hooks/telegram |
случайный action_token доставки; Telegram дополнительно проверяет X-Telegram-Bot-Api-Secret-Token |
/api/*, /a/* и /hooks/* в Caddy идут мимо forward_auth, всё остальное
через него. X-Remote-User на этих маршрутах игнорируется.
12. Деплой
Docker compose, как у sekai:
app— serversideup/php, HTTP.scheduler— тот же образ,php artisan schedule:work, тик раз в минуту.worker— тот же образ,php artisan queue:work, доставки и опросы присутствия.postgres.- Cache/queue driver —
databaseна старте (одна зависимость меньше); Redis, если упрёмся.
Env: APP_URL, DB_*, HADO_DEFAULT_TZ, TELEGRAM_BOT_TOKEN,
TELEGRAM_WEBHOOK_SECRET, HADO_PRESENCE_TIMEOUT=2, HADO_FALLBACK_DAY=30,
HADO_FALLBACK_HOUR=10, HADO_FALLBACK_HOURS=9,14,20, HADO_RETENTION_DAYS=90.
В Caddy хаба один vhost hado.<домен> с двумя матчерами: публичные пути
(/api/*, /a/*, /hooks/*) напрямую, остальное через forward_auth.
13. Тесты
Время везде через Carbon::setTestNow, присутствие через фейковые каналы.
Юнит:
- Каскад: таблица из §7, включая
fire_on = день срока→ пусто. - Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом.
quiet_untilпо трём состояниям из §8.- Алгоритм окна: доставил только в
present; ждал при отсутствии; fallback во все за N минут до конца; вtodayfallback только в трёх окнах; одно окно — одна доставка (строкаpendingставится до job-а, повторный тик её видит);quiet_untilблокирует; окноtodayобрезается моментом срока; сменаtzсдвигает окна; тихие часы. - Режимы срока:
localедет заtz,fixedне едет (вылет 08:15+02:00 приtz = America/Montevideoдаёт момент 03:15 и стартtodayв 20:00 накануне). - Ранний срок:
due_timeраньшеquiet_end→todayначинается накануне вquiet_start − 2h, а не уходит вexpiredбез единого окна. - Перенос: upsert с новым
due_atсбрасывает состояние, в том числе изtodayи из терминального (это задокументированное поведение для переносов, тест фиксирует его, чтобы контракт §5 про отдельные строки на вхождения не забыли). - Вхождения: два события с одним
source_refи разнымиtopicживут независимо,overdueсентября не мешаетscheduledоктября. - Обрезанное окно: срок 9:30 → fallback в 9:20; срок 11:15 → окно 11:00–11:15
становится fallback-окном, хотя 11 нет в
HADO_FALLBACK_HOURS. - Retention: терминальные события старше
HADO_RETENTION_DAYSудаляются, живые нет.
Фичевые (HTTP):
- Upsert пачкой: создание, повтор с тем же
due_at(payload обновился,doneостался), повтор с новымdue_at(реанимация изdone), 422 на плохую запись, 401 на чужой токен. - DELETE →
withdrawn; GET состояния. /me/*подX-Remote-User: автосоздание пользователя,ack/done, бейдж./a/{token}/doneзакрывает событие; неверный токен → 404.- Telegram: webhook с секретом, callback
ack:/done:, привязка через код.
Каналы с Http::fake(): доставка и presence у webhook (в т.ч. таймаут →
unknown), Telegram sendMessage и editMessageReplyMarkup.
14. Отвергнутые варианты
- Ядро опрашивает клиентов (
derive). Заставляло бы каждый клиент хранить и планировать уведомления у себя. Шина работает наоборот: клиенты пушат. - Тип события как enum
day-of / deadline. Склеивал два независимых свойства (есть ли подготовка, актуально ли после срока). Заменён наfire_at = due_atиafter_due. - Фиксированное расписание в ядре (−30, −15, −7). Не подходит событиям с
разной глубиной подготовки. Заменено каскадом половинок от
fire_on. - Пояс всегда из
due_at. Пользователь переезжает, для ДР и сроков документов пояс — его свойство, не события. Но для вылета пояс — свойство события, поэтому вместо «всегда» — два режима (§4): смещение вdue_atесть →fixed, нет →local. - Telegram как канал с присутствием. Бот не видит онлайн-статус, presence
почти всегда
unknown. Признано: Telegram — fallback-канал, часовая частотаtodayдля него не работает, иначе 13 сообщений в день. - Таблица шагов (
nudges). Одно полеquiet_untilплюс лог доставок покрывают то же самое без второй сущности. snoozed_untilи ручное откладывание. Никто не будет вручную двигать даты; «Помню» делает это по правилу состояния.