docs: spec review round 2 — per-occurrence rows for recurring events, truncated-window fallback, eve-rule wording
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
@@ -63,7 +63,7 @@ events
|
||||
source_id FK sources
|
||||
user_id FK users
|
||||
source_ref string -- "person:42"
|
||||
topic string -- "birthday", по умолчанию ""
|
||||
topic string -- "birthday:2026", "payment:2026-10"; по умолчанию ""
|
||||
due_mode enum(local, fixed)
|
||||
-- local: срок календарный, живёт в поясе пользователя (ДР, седула)
|
||||
-- fixed: срок — момент во времени, пояс зашит клиентом (вылет из Мадрида)
|
||||
@@ -129,7 +129,7 @@ PUT /api/events
|
||||
{
|
||||
"user": "nikita",
|
||||
"source_ref": "person:42",
|
||||
"topic": "birthday",
|
||||
"topic": "birthday:2026",
|
||||
"due_at": "2026-08-14", // или "2026-09-10T14:00"
|
||||
"fire_at": "2026-08-14", // = due_at, если подготовки нет
|
||||
"after_due": "expire",
|
||||
@@ -142,7 +142,7 @@ PUT /api/events
|
||||
}
|
||||
]
|
||||
}
|
||||
→ 200 { "events": [ { "source_ref": "person:42", "topic": "birthday", "id": 17, "state": "scheduled" } ] }
|
||||
→ 200 { "events": [ { "source_ref": "person:42", "topic": "birthday:2026", "id": 17, "state": "scheduled" } ] }
|
||||
```
|
||||
|
||||
Правила разбора `due_at`, три формы:
|
||||
@@ -169,23 +169,34 @@ PUT /api/events
|
||||
| `due_at` другой | это новый заход (ДР следующего года, перенесённый рейс): обновляет всё, сбрасывает `quiet_until`, пересчитывает состояние из дат **даже из терминального**. |
|
||||
|
||||
Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с
|
||||
тем же `person:42` + `birthday` и новым `due_at`.
|
||||
тем же `person:42` и новым `topic` — см. правило ниже.
|
||||
|
||||
**Обязательное правило для повторяющихся событий.** Ядро не отличает «клиент
|
||||
перевёл ДР на следующий год» от «рейс перенесли в день вылета» — оба легитимны,
|
||||
оба выглядят как новый `due_at`. Поэтому клиент **не переключает событие на
|
||||
следующее вхождение, пока текущее не стало вчерашним**: следующий ДР считается
|
||||
как ближайший с датой `>= сегодня`, а не `> сегодня`. Иначе в само утро ДР клиент
|
||||
пришлёт дату следующего года, ядро сбросит живое `today` в `scheduled`, и
|
||||
напоминание исчезнет ровно в тот день, ради которого всё делалось. Для событий с
|
||||
`after_due: keep` клиент переключает вхождение только когда текущее в `done`
|
||||
(проверяется `GET /api/events`), иначе он сам оборвёт просроченный долбёж.
|
||||
**Повторяющиеся события: каждое вхождение — отдельная строка.** Клиент кладёт
|
||||
период в `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 → 204, state = withdrawn
|
||||
GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, due_at, ... }
|
||||
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` в запросе можно опустить — тогда пустая строка.
|
||||
@@ -212,9 +223,10 @@ GET /api/events?source_ref=person:42&topic=birthday → 200 { id, state, du
|
||||
это 00:00 дня срока. Но если момент срока раньше начала активных часов
|
||||
(вылет в 07:00 при `quiet_end = 09:00`), первое окно дня уже опоздало бы, и
|
||||
событие ушло в `expired`, не пикнув ни разу. Поэтому для таких событий старт дня
|
||||
срока — начало последнего часового окна **накануне**: `quiet_start − 2h`
|
||||
предыдущего дня (20:00 при `quiet_start = 22:00`). Вечер накануне работает как
|
||||
`today`: часовые окна, бейдж, всё как в день.
|
||||
срока — **за два часа до тихих часов накануне**: `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`: пользователь нажал
|
||||
@@ -268,7 +280,11 @@ while d >= 1:
|
||||
такой бот замьютят через неделю — ровно та петля, от которой уходим. Поэтому
|
||||
fallback-доставка в `today` ограничена тремя окнами: утро, день, вечер.
|
||||
|
||||
Если момент срока в `today` наступает посреди окна, окно обрезается по нему.
|
||||
Если момент срока в `today` наступает посреди окна, окно обрезается по нему, и
|
||||
два следствия: fallback-запас считается **от обрезанного конца** (срок 9:30 →
|
||||
fallback в 9:20, а не в 9:50 за краем), а само обрезанное окно **всегда
|
||||
fallback-окно**, независимо от `HADO_FALLBACK_HOURS`: это последний шанс перед
|
||||
сроком, его нельзя пропустить из-за того, что час не попал в список.
|
||||
|
||||
Событие получает **не больше одной доставки на окно** (`deliveries.window_start`),
|
||||
и только если `now >= quiet_until`.
|
||||
@@ -429,7 +445,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_HOURS=9,14,20`.
|
||||
`HADO_FALLBACK_HOUR=10`, `HADO_FALLBACK_HOURS=9,14,20`, `HADO_RETENTION_DAYS=90`.
|
||||
|
||||
В Caddy хаба один vhost `hado.<домен>` с двумя матчерами: публичные пути
|
||||
(`/api/*`, `/a/*`, `/hooks/*`) напрямую, остальное через `forward_auth`.
|
||||
@@ -451,8 +467,14 @@ Env: `APP_URL`, `DB_*`, `HADO_DEFAULT_TZ`, `TELEGRAM_BOT_TOKEN`,
|
||||
`tz = America/Montevideo` даёт момент 03:15 и старт `today` в 20:00 накануне).
|
||||
- Ранний срок: `due_time` раньше `quiet_end` → `today` начинается накануне в
|
||||
`quiet_start − 2h`, а не уходит в `expired` без единого окна.
|
||||
- Годовой ре-пуш: upsert с новым `due_at` в день `today` сбрасывает событие (это
|
||||
задокументированное поведение, тест фиксирует его, чтобы контракт §5 не забыли).
|
||||
- Перенос: 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`
|
||||
|
||||
Reference in New Issue
Block a user