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
|
||||
source_ref string -- "person:42"
|
||||
topic string -- "birthday", по умолчанию ""
|
||||
due_date date
|
||||
due_time time -- момент срока внутри дня; 24:00:00 = весь день
|
||||
fire_on date -- старт подготовки; = due_date, если подготовки нет
|
||||
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)
|
||||
@@ -86,12 +91,26 @@ deliveries
|
||||
id, event_id FK, channel_id FK,
|
||||
window_start timestamptz -- начало окна, в которое доставлено
|
||||
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)
|
||||
```
|
||||
|
||||
Даты события хранятся **как календарные, без пояса**. Пояс берётся у пользователя
|
||||
в момент вычисления. Переехал в другую страну, сменил `tz` — все окна сдвинулись
|
||||
сами, пересчитывать нечего.
|
||||
Два режима срока. `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 с), в
|
||||
таблицах его нет.
|
||||
@@ -126,12 +145,21 @@ PUT /api/events
|
||||
→ 200 { "events": [ { "source_ref": "person:42", "topic": "birthday", "id": 17, "state": "scheduled" } ] }
|
||||
```
|
||||
|
||||
Правила разбора `due_at`: строка `YYYY-MM-DD` → `due_date`, `due_time = 24:00`
|
||||
(весь день). Строка `YYYY-MM-DDTHH:MM` → `due_date` + `due_time`. Смещение в
|
||||
строке игнорируется, дата календарная. `fire_at` — только `YYYY-MM-DD`, не
|
||||
позже `due_date`. Валидация: `fire_at <= due_at`, `user` непустой, `payload.title`
|
||||
непустой, до 500 событий в пачке. Одна плохая запись → 422 на всю пачку с
|
||||
указанием индекса, ничего не применяется.
|
||||
Правила разбора `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 по существующему событию (та же тройка идентичности):
|
||||
|
||||
@@ -143,6 +171,16 @@ PUT /api/events
|
||||
Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с
|
||||
тем же `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 | смысл | напоминает | в бейдже |
|
||||
|---|---|---|---|
|
||||
| `scheduled` | день старта ещё не пришёл | нет | нет |
|
||||
| `preparing` | идёт подготовка (`fire_on <= сегодня < due_date`) | раз в день | нет |
|
||||
| `preparing` | идёт подготовка (`fire_on <= сегодня < день срока`) | раз в день | нет |
|
||||
| `today` | день срока, момент срока ещё не наступил | каждый час | да |
|
||||
| `overdue` | момент срока прошёл, `after_due = keep` | раз в день | да |
|
||||
| `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 → today`, `preparing → today`: наступило 00:00 дня `due_date`.
|
||||
- `today → overdue`: наступил момент `due_date + due_time`, `after_due = keep`.
|
||||
- `today → expired`: наступил момент `due_date + due_time`, `after_due = expire`.
|
||||
- `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 от клиента.
|
||||
@@ -187,13 +231,13 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
|
||||
## 7. Каскад
|
||||
|
||||
Точки подготовки нужны только для `preparing` и только чтобы знать, до какого
|
||||
дня молчать после «Помню». Не хранятся, считаются из `fire_on` и `due_date`:
|
||||
дня молчать после «Помню». Не хранятся, считаются из `fire_on` и дня срока (`due_day`, вычисленного по режиму из §4):
|
||||
|
||||
```
|
||||
d = due_date - fire_on // дней
|
||||
d = due_day - fire_on // дней
|
||||
points = []
|
||||
while d >= 1:
|
||||
points.append(due_date - d)
|
||||
points.append(due_day - d)
|
||||
d = d div 2
|
||||
```
|
||||
|
||||
@@ -212,11 +256,17 @@ while d >= 1:
|
||||
активных часов пользователя `[quiet_end, quiet_start)`, по умолчанию 9:00–22:00
|
||||
в его `tz`.
|
||||
|
||||
| state | окна | fallback-запас |
|
||||
| state | окна | fallback |
|
||||
|---|---|---|
|
||||
| `preparing` | одно в день: 9:00–22:00 | 30 мин |
|
||||
| `today` | каждый час: 9:00–10:00, 10:00–11:00, … 21:00–22:00 | 10 мин |
|
||||
| `overdue` | одно в день: 9:00–22:00 | 30 мин |
|
||||
| `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` наступает посреди окна, окно обрезается по нему.
|
||||
|
||||
@@ -227,7 +277,7 @@ while d >= 1:
|
||||
|
||||
| state | `quiet_until` |
|
||||
|---|---|
|
||||
| `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — 00:00 `due_date` |
|
||||
| `preparing` | 00:00 ближайшей точки каскада после сегодня; если точек больше нет — старт дня срока (§6) |
|
||||
| `today` | начало окна через одно (следующий час пропускаем) |
|
||||
| `overdue` | 00:00 послезавтра (завтрашнее окно пропускаем) |
|
||||
|
||||
@@ -263,8 +313,17 @@ interface Channel
|
||||
| канал | `present` | иначе |
|
||||
|---|---|---|
|
||||
| `webhook` (HA) | GET `presence_url` вернул `{"present": true}` | `absent` при `false`, `unknown` при ошибке/таймауте |
|
||||
| `telegram` | пользователь писал боту или жал кнопку за последние 10 мин | `unknown` (бот не видит онлайн-статус) |
|
||||
| `web` (инбокс) | вкладка инбокса шлёт `POST /me/heartbeat` раз в 30 с, последний был < 90 с назад | `absent` |
|
||||
| `telegram` | пользователь жал кнопку или писал боту за последние 10 мин | `unknown` (бот не видит онлайн-статус) |
|
||||
| `web` (инбокс) | последний `POST /me/heartbeat` был < 90 с назад | `absent` |
|
||||
|
||||
Честно про реальность: присутствие по-настоящему работает у HA и веба. Telegram
|
||||
почти всегда `unknown` (боту никто не пишет), поэтому он получает в основном
|
||||
fallback-доставки. Это осознанно: Telegram — запасной канал, не основной.
|
||||
|
||||
Heartbeat веба шлётся **только когда вкладка видима** (`visibilityState ===
|
||||
'visible'`) **и было движение мыши или клавиатуры за последние 3 минуты**.
|
||||
Забытая вкладка в фоне на десктопе в другой комнате не должна отвечать
|
||||
«пользователь тут», иначе она молча съест все реальные доставки в Telegram и HA.
|
||||
|
||||
`web` — обычный канал в таблице `channels`, создаётся автоматически вместе с
|
||||
пользователем, удалить нельзя. Его `deliver` ничего не шлёт (инбокс и так
|
||||
@@ -273,17 +332,32 @@ interface Channel
|
||||
вкладка, доставлять в Telegram незачем.
|
||||
|
||||
Алгоритм тика (раз в минуту) для каждого события в `preparing/today/overdue`,
|
||||
находящегося в открытом окне, без доставки в этом окне и с `now >= quiet_until`:
|
||||
находящегося в открытом окне, без строки в `deliveries` за это окно и с
|
||||
`now >= quiet_until`:
|
||||
|
||||
1. Спросить `presence()` у всех включённых каналов пользователя и у `web`.
|
||||
Параллельно, таймаут 2 с, ответ кэшируется на 60 с.
|
||||
2. Есть `present` → доставить только в них. Окно закрыто.
|
||||
3. Никого `present`, до конца окна больше fallback-запаса → ждать следующего тика.
|
||||
4. Никого `present`, до конца окна меньше fallback-запаса → доставить во **все**
|
||||
3. Никого `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`,
|
||||
`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.<домен>` с двумя матчерами: публичные пути
|
||||
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
|
||||
@@ -365,12 +439,20 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
|
||||
Время везде через `Carbon::setTestNow`, присутствие через фейковые каналы.
|
||||
|
||||
Юнит:
|
||||
- Каскад: таблица из §7, включая `fire_on = due_date` → пусто.
|
||||
- Каскад: таблица из §7, включая `fire_on = день срока` → пусто.
|
||||
- Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом.
|
||||
- `quiet_until` по трём состояниям из §8.
|
||||
- Алгоритм окна: доставил только в `present`; ждал при отсутствии; fallback во все
|
||||
за N минут до конца; одно окно — одна доставка; `quiet_until` блокирует; окно
|
||||
`today` обрезается моментом срока; смена `tz` сдвигает окна; тихие часы.
|
||||
за N минут до конца; в `today` fallback только в трёх окнах; одно окно — одна
|
||||
доставка (строка `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):
|
||||
- Upsert пачкой: создание, повтор с тем же `due_at` (payload обновился, `done`
|
||||
@@ -393,7 +475,13 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
|
||||
и `after_due`.
|
||||
- **Фиксированное расписание в ядре (−30, −15, −7).** Не подходит событиям с
|
||||
разной глубиной подготовки. Заменено каскадом половинок от `fire_on`.
|
||||
- **Пояс из `due_at`.** Пользователь переезжает, пояс — его свойство, не события.
|
||||
- **Пояс всегда из `due_at`.** Пользователь переезжает, для ДР и сроков документов
|
||||
пояс — его свойство, не события. Но для вылета пояс — свойство события, поэтому
|
||||
вместо «всегда» — два режима (§4): смещение в `due_at` есть → `fixed`, нет →
|
||||
`local`.
|
||||
- **Telegram как канал с присутствием.** Бот не видит онлайн-статус, presence
|
||||
почти всегда `unknown`. Признано: Telegram — fallback-канал, часовая частота
|
||||
`today` для него не работает, иначе 13 сообщений в день.
|
||||
- **Таблица шагов (`nudges`).** Одно поле `quiet_until` плюс лог доставок
|
||||
покрывают то же самое без второй сущности.
|
||||
- **`snoozed_until` и ручное откладывание.** Никто не будет вручную двигать даты;
|
||||
|
||||
Reference in New Issue
Block a user