docs: spec review fixes — recurrence rule, fallback caps, web presence, fixed-mode due, delivery race, early due_time
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
@@ -64,9 +64,14 @@ events
|
|||||||
user_id FK users
|
user_id FK users
|
||||||
source_ref string -- "person:42"
|
source_ref string -- "person:42"
|
||||||
topic string -- "birthday", по умолчанию ""
|
topic string -- "birthday", по умолчанию ""
|
||||||
due_date date
|
due_mode enum(local, fixed)
|
||||||
due_time time -- момент срока внутри дня; 24:00:00 = весь день
|
-- local: срок календарный, живёт в поясе пользователя (ДР, седула)
|
||||||
fire_on date -- старт подготовки; = due_date, если подготовки нет
|
-- 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)
|
after_due enum(keep, expire)
|
||||||
payload jsonb -- title, subtitle, deep_link, done_label
|
payload jsonb -- title, subtitle, deep_link, done_label
|
||||||
state enum(scheduled, preparing, today, overdue, done, expired, withdrawn)
|
state enum(scheduled, preparing, today, overdue, done, expired, withdrawn)
|
||||||
@@ -86,12 +91,26 @@ deliveries
|
|||||||
id, event_id FK, channel_id FK,
|
id, event_id FK, channel_id FK,
|
||||||
window_start timestamptz -- начало окна, в которое доставлено
|
window_start timestamptz -- начало окна, в которое доставлено
|
||||||
action_token string UNIQUE -- для ссылок «Помню»/«Сделано» из канала
|
action_token string UNIQUE -- для ссылок «Помню»/«Сделано» из канала
|
||||||
sent_at, result enum(ok, failed), error text nullable
|
result enum(pending, ok, failed) -- pending ставится в момент диспатча
|
||||||
|
sent_at timestamptz nullable, error text nullable
|
||||||
|
UNIQUE (event_id, channel_id, window_start)
|
||||||
```
|
```
|
||||||
|
|
||||||
Даты события хранятся **как календарные, без пояса**. Пояс берётся у пользователя
|
Два режима срока. `local` — дата календарная, без пояса; пояс берётся у
|
||||||
в момент вычисления. Переехал в другую страну, сменил `tz` — все окна сдвинулись
|
пользователя в момент вычисления. Переехал в другую страну, сменил `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 с), в
|
Кэш присутствия живёт в cache-store (ключ `presence:{channel_id}`, TTL 60 с), в
|
||||||
таблицах его нет.
|
таблицах его нет.
|
||||||
@@ -126,12 +145,21 @@ PUT /api/events
|
|||||||
→ 200 { "events": [ { "source_ref": "person:42", "topic": "birthday", "id": 17, "state": "scheduled" } ] }
|
→ 200 { "events": [ { "source_ref": "person:42", "topic": "birthday", "id": 17, "state": "scheduled" } ] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Правила разбора `due_at`: строка `YYYY-MM-DD` → `due_date`, `due_time = 24:00`
|
Правила разбора `due_at`, три формы:
|
||||||
(весь день). Строка `YYYY-MM-DDTHH:MM` → `due_date` + `due_time`. Смещение в
|
|
||||||
строке игнорируется, дата календарная. `fire_at` — только `YYYY-MM-DD`, не
|
| форма | пример | режим | что хранится |
|
||||||
позже `due_date`. Валидация: `fire_at <= due_at`, `user` непустой, `payload.title`
|
|---|---|---|---|
|
||||||
непустой, до 500 событий в пачке. Одна плохая запись → 422 на всю пачку с
|
| дата | `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 по существующему событию (та же тройка идентичности):
|
Правила upsert по существующему событию (та же тройка идентичности):
|
||||||
|
|
||||||
@@ -143,6 +171,16 @@ PUT /api/events
|
|||||||
Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с
|
Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с
|
||||||
тем же `person:42` + `birthday` и новым `due_at`.
|
тем же `person:42` + `birthday` и новым `due_at`.
|
||||||
|
|
||||||
|
**Обязательное правило для повторяющихся событий.** Ядро не отличает «клиент
|
||||||
|
перевёл ДР на следующий год» от «рейс перенесли в день вылета» — оба легитимны,
|
||||||
|
оба выглядят как новый `due_at`. Поэтому клиент **не переключает событие на
|
||||||
|
следующее вхождение, пока текущее не стало вчерашним**: следующий ДР считается
|
||||||
|
как ближайший с датой `>= сегодня`, а не `> сегодня`. Иначе в само утро ДР клиент
|
||||||
|
пришлёт дату следующего года, ядро сбросит живое `today` в `scheduled`, и
|
||||||
|
напоминание исчезнет ровно в тот день, ради которого всё делалось. Для событий с
|
||||||
|
`after_due: keep` клиент переключает вхождение только когда текущее в `done`
|
||||||
|
(проверяется `GET /api/events`), иначе он сам оборвёт просроченный долбёж.
|
||||||
|
|
||||||
### Снятие и чтение
|
### Снятие и чтение
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -160,7 +198,7 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
|
|||||||
| state | смысл | напоминает | в бейдже |
|
| state | смысл | напоминает | в бейдже |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `scheduled` | день старта ещё не пришёл | нет | нет |
|
| `scheduled` | день старта ещё не пришёл | нет | нет |
|
||||||
| `preparing` | идёт подготовка (`fire_on <= сегодня < due_date`) | раз в день | нет |
|
| `preparing` | идёт подготовка (`fire_on <= сегодня < день срока`) | раз в день | нет |
|
||||||
| `today` | день срока, момент срока ещё не наступил | каждый час | да |
|
| `today` | день срока, момент срока ещё не наступил | каждый час | да |
|
||||||
| `overdue` | момент срока прошёл, `after_due = keep` | раз в день | да |
|
| `overdue` | момент срока прошёл, `after_due = keep` | раз в день | да |
|
||||||
| `done` | нажал «Сделано» | нет | нет |
|
| `done` | нажал «Сделано» | нет | нет |
|
||||||
@@ -169,10 +207,16 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
|
|||||||
|
|
||||||
Переходы:
|
Переходы:
|
||||||
|
|
||||||
- `scheduled → preparing`: наступило 00:00 дня `fire_on`, и `fire_on < due_date`.
|
- `scheduled → preparing`: наступило 00:00 дня `fire_on`, и `fire_on` раньше дня срока.
|
||||||
- `scheduled → today`, `preparing → today`: наступило 00:00 дня `due_date`.
|
- `scheduled → today`, `preparing → today`: наступил **старт дня срока**. Обычно
|
||||||
- `today → overdue`: наступил момент `due_date + due_time`, `after_due = keep`.
|
это 00:00 дня срока. Но если момент срока раньше начала активных часов
|
||||||
- `today → expired`: наступил момент `due_date + due_time`, `after_due = expire`.
|
(вылет в 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 / preparing / today / overdue` → `done`: пользователь нажал
|
||||||
«Сделано» (из `scheduled` — заранее, из инбокса).
|
«Сделано» (из `scheduled` — заранее, из инбокса).
|
||||||
- любое → `withdrawn`: DELETE от клиента.
|
- любое → `withdrawn`: DELETE от клиента.
|
||||||
@@ -187,13 +231,13 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
|
|||||||
## 7. Каскад
|
## 7. Каскад
|
||||||
|
|
||||||
Точки подготовки нужны только для `preparing` и только чтобы знать, до какого
|
Точки подготовки нужны только для `preparing` и только чтобы знать, до какого
|
||||||
дня молчать после «Помню». Не хранятся, считаются из `fire_on` и `due_date`:
|
дня молчать после «Помню». Не хранятся, считаются из `fire_on` и дня срока (`due_day`, вычисленного по режиму из §4):
|
||||||
|
|
||||||
```
|
```
|
||||||
d = due_date - fire_on // дней
|
d = due_day - fire_on // дней
|
||||||
points = []
|
points = []
|
||||||
while d >= 1:
|
while d >= 1:
|
||||||
points.append(due_date - d)
|
points.append(due_day - d)
|
||||||
d = d div 2
|
d = d div 2
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,11 +256,17 @@ while d >= 1:
|
|||||||
активных часов пользователя `[quiet_end, quiet_start)`, по умолчанию 9:00–22:00
|
активных часов пользователя `[quiet_end, quiet_start)`, по умолчанию 9:00–22:00
|
||||||
в его `tz`.
|
в его `tz`.
|
||||||
|
|
||||||
| state | окна | fallback-запас |
|
| state | окна | fallback |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `preparing` | одно в день: 9:00–22:00 | 30 мин |
|
| `preparing` | одно в день: 9:00–22:00 | за 30 мин до конца окна |
|
||||||
| `today` | каждый час: 9:00–10:00, 10:00–11:00, … 21:00–22:00 | 10 мин |
|
| `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 мин |
|
| `overdue` | одно в день: 9:00–22:00 | за 30 мин до конца окна |
|
||||||
|
|
||||||
|
Часовая частота в `today` — только для каналов, которые сказали `present`: ты
|
||||||
|
рядом, тебя можно дёрнуть, ты ответишь за минуту. Если никто не `present`,
|
||||||
|
стрелять наугад каждый час нельзя: это до 13 сообщений в день в Telegram, и
|
||||||
|
такой бот замьютят через неделю — ровно та петля, от которой уходим. Поэтому
|
||||||
|
fallback-доставка в `today` ограничена тремя окнами: утро, день, вечер.
|
||||||
|
|
||||||
Если момент срока в `today` наступает посреди окна, окно обрезается по нему.
|
Если момент срока в `today` наступает посреди окна, окно обрезается по нему.
|
||||||
|
|
||||||
@@ -227,7 +277,7 @@ while d >= 1:
|
|||||||
|
|
||||||
| state | `quiet_until` |
|
| state | `quiet_until` |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — 00:00 `due_date` |
|
| `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — старт дня срока (§6) |
|
||||||
| `today` | начало окна через одно (следующий час пропускаем) |
|
| `today` | начало окна через одно (следующий час пропускаем) |
|
||||||
| `overdue` | 00:00 послезавтра (завтрашнее окно пропускаем) |
|
| `overdue` | 00:00 послезавтра (завтрашнее окно пропускаем) |
|
||||||
|
|
||||||
@@ -263,8 +313,17 @@ interface Channel
|
|||||||
| канал | `present` | иначе |
|
| канал | `present` | иначе |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `webhook` (HA) | GET `presence_url` вернул `{"present": true}` | `absent` при `false`, `unknown` при ошибке/таймауте |
|
| `webhook` (HA) | GET `presence_url` вернул `{"present": true}` | `absent` при `false`, `unknown` при ошибке/таймауте |
|
||||||
| `telegram` | пользователь писал боту или жал кнопку за последние 10 мин | `unknown` (бот не видит онлайн-статус) |
|
| `telegram` | пользователь жал кнопку или писал боту за последние 10 мин | `unknown` (бот не видит онлайн-статус) |
|
||||||
| `web` (инбокс) | вкладка инбокса шлёт `POST /me/heartbeat` раз в 30 с, последний был < 90 с назад | `absent` |
|
| `web` (инбокс) | последний `POST /me/heartbeat` был < 90 с назад | `absent` |
|
||||||
|
|
||||||
|
Честно про реальность: присутствие по-настоящему работает у HA и веба. Telegram
|
||||||
|
почти всегда `unknown` (боту никто не пишет), поэтому он получает в основном
|
||||||
|
fallback-доставки. Это осознанно: Telegram — запасной канал, не основной.
|
||||||
|
|
||||||
|
Heartbeat веба шлётся **только когда вкладка видима** (`visibilityState ===
|
||||||
|
'visible'`) **и было движение мыши или клавиатуры за последние 3 минуты**.
|
||||||
|
Забытая вкладка в фоне на десктопе в другой комнате не должна отвечать
|
||||||
|
«пользователь тут», иначе она молча съест все реальные доставки в Telegram и HA.
|
||||||
|
|
||||||
`web` — обычный канал в таблице `channels`, создаётся автоматически вместе с
|
`web` — обычный канал в таблице `channels`, создаётся автоматически вместе с
|
||||||
пользователем, удалить нельзя. Его `deliver` ничего не шлёт (инбокс и так
|
пользователем, удалить нельзя. Его `deliver` ничего не шлёт (инбокс и так
|
||||||
@@ -273,17 +332,32 @@ interface Channel
|
|||||||
вкладка, доставлять в Telegram незачем.
|
вкладка, доставлять в Telegram незачем.
|
||||||
|
|
||||||
Алгоритм тика (раз в минуту) для каждого события в `preparing/today/overdue`,
|
Алгоритм тика (раз в минуту) для каждого события в `preparing/today/overdue`,
|
||||||
находящегося в открытом окне, без доставки в этом окне и с `now >= quiet_until`:
|
находящегося в открытом окне, без строки в `deliveries` за это окно и с
|
||||||
|
`now >= quiet_until`:
|
||||||
|
|
||||||
1. Спросить `presence()` у всех включённых каналов пользователя и у `web`.
|
1. Спросить `presence()` у всех включённых каналов пользователя и у `web`.
|
||||||
Параллельно, таймаут 2 с, ответ кэшируется на 60 с.
|
Параллельно, таймаут 2 с, ответ кэшируется на 60 с.
|
||||||
2. Есть `present` → доставить только в них. Окно закрыто.
|
2. Есть `present` → доставить только в них. Окно закрыто.
|
||||||
3. Никого `present`, до конца окна больше fallback-запаса → ждать следующего тика.
|
3. Никого `present`, окно не fallback-окно или до его конца больше запаса →
|
||||||
4. Никого `present`, до конца окна меньше fallback-запаса → доставить во **все**
|
ждать следующего тика.
|
||||||
|
4. Никого `present`, fallback-окно, до конца меньше запаса → доставить во **все**
|
||||||
включённые каналы. Лучше один раз наугад, чем пропустить окно.
|
включённые каналы. Лучше один раз наугад, чем пропустить окно.
|
||||||
|
|
||||||
Сама доставка — job в очереди с ретраями (3 попытки, экспоненциально), чтобы
|
**Защита от двойной доставки.** «Доставить» в тике означает: вставить строку
|
||||||
медленный HA не тормозил тик. Результат каждой попытки в `deliveries`.
|
`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) тоже отслеживается с точностью до минуты.
|
||||||
|
|
||||||
У пользователя без каналов доставок нет, события видны только в инбоксе. Это
|
У пользователя без каналов доставок нет, события видны только в инбоксе. Это
|
||||||
нормальное состояние, не ошибка.
|
нормальное состояние, не ошибка.
|
||||||
@@ -355,7 +429,7 @@ Docker compose, как у sekai:
|
|||||||
|
|
||||||
Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
|
Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
|
||||||
`TELEGRAM_WEBHOOK_SECRET`, `HADO_PRESENCE_TIMEOUT=2`, `HADO_FALLBACK_DAY=30`,
|
`TELEGRAM_WEBHOOK_SECRET`, `HADO_PRESENCE_TIMEOUT=2`, `HADO_FALLBACK_DAY=30`,
|
||||||
`HADO_FALLBACK_HOUR=10`.
|
`HADO_FALLBACK_HOUR=10`, `HADO_FALLBACK_HOURS=9,14,20`.
|
||||||
|
|
||||||
В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
|
В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
|
||||||
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
|
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
|
||||||
@@ -365,12 +439,20 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
|
|||||||
Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.
|
Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.
|
||||||
|
|
||||||
Юнит:
|
Юнит:
|
||||||
- Каскад: таблица из §7, включая `fire_on = due_date` → пусто.
|
- Каскад: таблица из §7, включая `fire_on = день срока` → пусто.
|
||||||
- Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом.
|
- Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом.
|
||||||
- `quiet_until` по трём состояниям из §8.
|
- `quiet_until` по трём состояниям из §8.
|
||||||
- Алгоритм окна: доставил только в `present`; ждал при отсутствии; fallback во все
|
- Алгоритм окна: доставил только в `present`; ждал при отсутствии; fallback во все
|
||||||
за N минут до конца; одно окно — одна доставка; `quiet_until` блокирует; окно
|
за N минут до конца; в `today` fallback только в трёх окнах; одно окно — одна
|
||||||
`today` обрезается моментом срока; смена `tz` сдвигает окна; тихие часы.
|
доставка (строка `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 не забыли).
|
||||||
|
|
||||||
Фичевые (HTTP):
|
Фичевые (HTTP):
|
||||||
- Upsert пачкой: создание, повтор с тем же `due_at` (payload обновился, `done`
|
- Upsert пачкой: создание, повтор с тем же `due_at` (payload обновился, `done`
|
||||||
@@ -393,7 +475,13 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
|
|||||||
и `after_due`.
|
и `after_due`.
|
||||||
- **Фиксированное расписание в ядре (−30, −15, −7).** Не подходит событиям с
|
- **Фиксированное расписание в ядре (−30, −15, −7).** Не подходит событиям с
|
||||||
разной глубиной подготовки. Заменено каскадом половинок от `fire_on`.
|
разной глубиной подготовки. Заменено каскадом половинок от `fire_on`.
|
||||||
- **Пояс из `due_at`.** Пользователь переезжает, пояс — его свойство, не события.
|
- **Пояс всегда из `due_at`.** Пользователь переезжает, для ДР и сроков документов
|
||||||
|
пояс — его свойство, не события. Но для вылета пояс — свойство события, поэтому
|
||||||
|
вместо «всегда» — два режима (§4): смещение в `due_at` есть → `fixed`, нет →
|
||||||
|
`local`.
|
||||||
|
- **Telegram как канал с присутствием.** Бот не видит онлайн-статус, presence
|
||||||
|
почти всегда `unknown`. Признано: Telegram — fallback-канал, часовая частота
|
||||||
|
`today` для него не работает, иначе 13 сообщений в день.
|
||||||
- **Таблица шагов (`nudges`).** Одно поле `quiet_until` плюс лог доставок
|
- **Таблица шагов (`nudges`).** Одно поле `quiet_until` плюс лог доставок
|
||||||
покрывают то же самое без второй сущности.
|
покрывают то же самое без второй сущности.
|
||||||
- **`snoozed_until` и ручное откладывание.** Никто не будет вручную двигать даты;
|
- **`snoozed_until` и ручное откладывание.** Никто не будет вручную двигать даты;
|
||||||
|
|||||||
Reference in New Issue
Block a user