diff --git a/docs/superpowers/specs/2026-09-03-hado-core-design.md b/docs/superpowers/specs/2026-09-03-hado-core-design.md index 854015a..f5d2c03 100644 --- a/docs/superpowers/specs/2026-09-03-hado-core-design.md +++ b/docs/superpowers/specs/2026-09-03-hado-core-design.md @@ -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` и ручное откладывание.** Никто не будет вручную двигать даты;