docs: design handoff, manual events and archive in plan, spec and functional doc

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
2026-09-04 01:09:54 -03:00
parent d80e70ae22
commit c26a2cd7e4
11 changed files with 3758 additions and 145 deletions

View File

@@ -121,9 +121,19 @@ Hado — сервис напоминаний. Другие сервисы (до
подпись кнопки «Сделано» и две ссылки-действия. Как это озвучивается или показывается —
на стороне умного дома.
## Добавлено по дизайну (хендофф 2026-09-04, `handoff/`)
**Кнопка «+ Добавить»** в шапке раскрывает форму «Новое событие» над группами:
заголовок (обязателен), подзаголовок, срок (обязателен), время. «Создать» кладёт
событие в группу по дате: прошла → Просрочено, сегодня → Сегодня, в пределах
30 дней → Грядёт, дальше → Запланировано. Источник подписывается «вручную», кнопка
закрытия — «Сделано». «Отмена» сворачивает форму. Ошибки валидации под формой.
**Архив** — сворачиваемый блок над настройками, в заголовке «N закрыто». Строки:
заголовок, источник и срок, ярлык закрытия (подпись кнопки, которой закрыли, или
«Истекло», если срок прошёл сам). Только чтение, действий нет. Глубина — 90 дней.
## Чего здесь нет намеренно
- Истории закрытых событий: закрыл — исчезло, архива нет.
- Ручного создания событий: их присылают только сервисы-источники.
- Откладывания «на дату»: этим занимается правило «Помню».
- Настройки частоты напоминаний: она общая и не настраивается.
- Мобильного приложения: iOS с бейджем, виджетом «Грядёт» и Live Activity — отдельный

View File

@@ -0,0 +1,80 @@
# Hado 波動 — design handoff
Сервис напоминаний экосистемы Sekai (世界). Планета № 05.
Дизайн-система: Sekai — 和風幻想 (dark only, токены из tokens/colors.css).
## Файлы пакета
- `hado-inbox.dc.html` + `support.js` — рабочий прототип веб-инбокса (открыть в браузере, файлы держать рядом)
- `hado-planet.dc.html` — карточка планеты Hado отдельно
- `sekai-planets.html` — все 5 планет экосистемы, автономный HTML (формат исходного planets.html)
- `sekai-template.html` — исходный шаблон страницы сервиса (каркас, туманность, сёстры-планеты)
- `sekai-nebula.html` — исходный компонент туманности
- `functional-spec.md` — исходное функциональное описание
## Планета 05 · HADO 波動
Hado = волна / пульс. Напоминание — сигнал, который уходит и возвращается.
- Градиент тела: `radial-gradient(circle at 32% 28%, var(--ruri), #24337F 55%, var(--ink-0) 92%)`
(瑠璃 ruri — «magic / pulse» → тёмный ruri #24337F; глубокий ультрамарин, чтобы не путать с ледяным Fumi)
- Свечение: `0 0 46px rgba(53,92,214,.4)`, пульс 2.6s
- Эхо-кольца: 2 расходящихся кольца (byakugun .45 / ruri .5), skEcho 4.2s со сдвигом 2.1s
- Кольца орбит: rotateX(69deg), внешнее пунктирное rotateZ(-8deg)
- Спутники: СИГНАЛ 10px `--byakugun` (skSpin 13s) + ЭХО 7px `--ruri` (skSpinBack 30s)
- Мини-версия для навигации: круг 11px, тот же градиент, box-shadow 0 0 8px rgba(53,92,214,.45)
## Страница инбокса (по sekai-template, без отступлений)
Слои фона: звёзды (90) → туман → ядро полосы → пылевые разломы → звёзды полосы (170, дуга 14°).
Сёстры-планеты (hover — подпись): YUME 17%/7%, MYAKU 26%/right 8%, KIOKU 60%/4.5%, FUMI 64%/right 6%. Своей планеты на странице нет.
Шапка: 波動 (Shippori Mincho 54px --washi) + HADO (Space Mono 13px .5em --nibi) +
бейдж-счётчик (мини-планета 11px с пульсом + число --byakugun, пилюля border ruri .55 / bg ruri .14).
Счётчик = «Сегодня» + «Просрочено»; при 0 скрыт. Заголовок вкладки: «(N) Hado».
Справа: «+ Добавить» (primary) и номер 05. Футер: 波動 HADO · 世界 SEKAI.
## Типографика
- UI: Space Grotesk; кандзи/заголовки групп: Shippori Mincho; мета/служебные: Space Mono
- Группы: кандзи 20px + русское имя Space Mono 11px .35em --kasumi + hairline
今日 СЕГОДНЯ · 遅延 ПРОСРОЧЕНО · 近日 ГРЯДЁТ · 予定 ЗАПЛАНИРОВАНО (порядок фиксирован; пустая — «ПУСТО»)
## Карточка события
bg rgba(27,33,72,.72), border --hair, radius 12, padding 16/20.
Слева плашка срока: --ink-1, border --hair-2, radius 10 — месяц (Mono 9px --nibi),
день (Shippori Mincho 24px --washi), время (Mono 10px --byakugun).
Заголовок 16px --washi (если есть ссылка в источник — dotted underline, hover --byakugun);
подзаголовок 13px --kasumi; служебная строка Mono 11px --nibi: «источник · срок YYYY-MM-DD · относительно» («сегодня», «через 6 дней», «3 дня назад» --kasumi).
Кнопки (принципиально разные):
- «Помню» — ghost: border --hair-2, текст --kasumi, hover --byakugun. Нет в «Запланировано».
- «Сделано» — primary: bg rgba(53,92,214,.22), border --ruri, текст --byakugun, hover bg .4.
Подпись задаёт источник («Поздравил», «Оплатил», «Продлил»).
Нажатие: кнопка блокируется (opacity .45) до ответа; «Сделано» уносит карточку в архив и уменьшает счётчик.
## Архив 書庫
Сворачиваемый блок над настройками, в заголовке счётчик «N закрыто».
Строки opacity .75: заголовок --kasumi, источник · срок Mono --nibi, ярлык закрытия — пилюля --fuji
(fuji = «quiet magic / decay»: закрытое событие угасает по лайфциклу системы).
## Ручное создание («+ Добавить»)
Форма под шапкой (border dashed --hair-2): заголовок*, подзаголовок, срок*, время.
Группа определяется датой: прошла → Просрочено, сегодня → Сегодня, ≤30 дней → Грядёт, дальше → Запланировано. Источник: «вручную».
## Настройки 設定 (свёрнуты, в заголовке логин)
- Часовой пояс: полный IANA-список (Intl.supportedValuesOf)
- Тихие часы: обязаны переходить через полночь (22:00→09:00 верно, 08:00→22:00 — ошибка --sakura)
- Каналы: Веб (без удаления) / Telegram / Webhook (несколько), «Удалить» — ghost, hover --nadeshiko
- Добавить webhook: адрес доставки* + адрес присутствия*, оба URL http(s)
- Подключить Telegram: одноразовый код «/start XXXXXX», живёт 15 минут
## Цветовые правила (60/30/10)
Сумрак ≥60% (фон, карточки, бордеры). Акцент сервиса — холодный полюс (hanada/ruri/byakugun),
только кнопки-действия, бейдж, время — ~10%. Ошибки — --sakura (светлый лепесток, читается).
Насыщенный --ruri никогда не используется как текст; текст — --washi/--kasumi/--nibi/--byakugun.

View File

@@ -0,0 +1,130 @@
# Hado · веб-инбокс — функциональное описание для дизайна
Что есть на экранах и что делает каждый элемент. Без визуальных требований:
размеры, цвета, сетка, стиль — на стороне дизайна.
## Контекст
Hado — сервис напоминаний. Другие сервисы (документы, люди, оплаты) присылают ему
события со сроком, Hado сам решает, когда и куда напомнить: сюда в инбокс, в Telegram,
в умный дом. Веб-инбокс — единственный экран Hado. Пользователь попадает в него уже
залогиненным через общий вход экосистемы, своей формы логина здесь нет.
Два действия над событием, они принципиально разные и должны читаться по-разному:
- **«Помню»** — «я в курсе, отстань до следующей контрольной точки». Событие остаётся,
счётчик не меняется. Напоминание вернётся само: в день события через час, до события
на следующей контрольной точке, после события через день.
- **«Сделано»** — «закрыл вопрос». Событие уходит с экрана навсегда, счётчик падает.
Подпись кнопки может задавать сервис-источник: «Поздравил», «Оплатил», «Продлил».
По умолчанию «Сделано».
## Понятия
| термин | что это |
|---|---|
| Событие | одна штука со сроком: ДР Васи, седула протухает 30 сен, вылет 10 сен 08:15 |
| Срок | день события, иногда с конкретным временем |
| Источник | сервис, который прислал событие (`docs`, `people`). Показывается как подпись |
| Счётчик | число событий в группах «Сегодня» и «Просрочено». Это же число потом будет бейджем на иконке iOS |
| Ссылка в источник | у события может быть ссылка на объект в сервисе-источнике (карточка человека, документ) |
## Экран 1. Инбокс (главная и единственная страница)
### Шапка
- Название «Hado».
- Счётчик: число событий «Сегодня» + «Просрочено». Если ноль — не показывается.
- Заголовок вкладки браузера дублирует счётчик: «(3) Hado», чтобы видеть его в списке вкладок.
### Группы событий
Четыре группы, всегда в этом порядке. Пустая группа показывает подпись «Пусто»
(или скрывается — решение дизайна, но порядок остальных не меняется).
1. **Сегодня** — срок сегодня, время ещё не наступило. Самое важное, наверху.
2. **Просрочено** — срок прошёл, но событие всё ещё надо сделать (седула истекла,
аренда не оплачена). Висит, пока не нажмёшь «Сделано».
3. **Грядёт** — идёт подготовка, срок через дни или недели. В счётчик не входит.
4. **Запланировано** — Hado про событие знает, но ещё молчит. В счётчик не входит.
Внутри группы события отсортированы по сроку, ближайший первым.
### Карточка события
- **Заголовок** — текст от источника («Сегодня ДР — Вася»). Если у события есть ссылка
в источник, заголовок — ссылка, открывает объект в сервисе-источнике (в той же вкладке
или новой — решение дизайна).
- **Подзаголовок** — вторая строка от источника, опциональная («исполняется 34»,
«осталось 30 дней»).
- **Служебная строка** — имя источника и срок: «docs · срок 2026-09-30». Если срок с
временем — «2026-09-10 08:15».
- **Кнопка «Помню»** — есть во всех группах, кроме «Запланировано» (там напоминаний
ещё нет, «помнить» нечего). После нажатия карточка остаётся на месте, ничего
визуально не должно намекать, что событие закрыто.
- **Кнопка «Сделано»** — есть везде, включая «Запланировано» (закрыть можно заранее,
хоть за два месяца). Подпись берётся у источника, если он её задал. После нажатия
карточка исчезает, счётчик уменьшается, если событие было в «Сегодня» или «Просрочено».
Нажатие любой кнопки: кнопка блокируется до ответа сервера, затем список обновляется.
При ошибке кнопка разблокируется, карточка остаётся.
### Блок «Настройки»
Свёрнут по умолчанию, раскрывается на той же странице. В заголовке блока — логин
пользователя.
**Пояс и тихие часы**
- Выбор часового пояса из полного списка (IANA: `America/Montevideo`, `Asia/Shanghai`).
Пользователь много переезжает и меняет пояс руками; все напоминания сдвигаются под него.
- Тихие часы: «с» и «до». По умолчанию с 22:00 до 09:00. В тихие часы Hado молчит во
всех каналах. Ограничение: интервал обязан переходить через полночь («с 22:00 до
09:00» верно, «с 08:00 до 22:00» — ошибка, показать сообщение).
- Кнопка «Сохранить». После сохранения страница обновляется.
**Каналы** — куда Hado доставляет напоминания. Список:
- **Веб** — этот инбокс. Есть всегда, удалить нельзя, кнопки удаления нет. Подпись
«этот инбокс».
- **Telegram** — подпись с идентификатором чата. Появляется после привязки (ниже).
Кнопка «Удалить».
- **Webhook** — адрес, куда Hado шлёт напоминание (в жизни это умный дом). Подпись —
сам адрес. Кнопка «Удалить». Таких может быть несколько.
**Добавить webhook** — форма из двух полей и кнопки:
- «Адрес доставки» — куда Hado отправит напоминание.
- «Адрес присутствия» — откуда Hado спросит «пользователь сейчас дома/рядом?».
- Кнопка «Добавить webhook». Оба поля обязательны, должны быть URL. Ошибки валидации
показываются у формы.
**Подключить Telegram** — кнопка. По нажатию появляется одноразовый код и ссылка на
бота: «Отправь боту `/start ABC123` или открой». Код живёт 15 минут. После того как
пользователь отправит код боту, канал Telegram появится в списке (при следующем
открытии страницы).
### Поведение страницы, невидимое пользователю
- Пока вкладка открыта, видима и пользователь что-то делал за последние 3 минуты,
страница сообщает серверу «я тут». Hado тогда не дёргает Telegram и умный дом:
напоминание считается доставленным в инбокс. Забытая фоновая вкладка «я тут» не
сообщает.
- Никаких всплывающих уведомлений и звуков в самом инбоксе нет: он пассивный экран.
Активные каналы — Telegram и умный дом, потом iOS.
## Экран 0. Нет доступа
Если пользователь пришёл не через общий вход экосистемы, страница не открывается
(ответ 401). Своей заглушки у Hado нет: общий вход сам перебросит на форму логина.
Дизайнить здесь нечего, упомянуто для полноты.
## Вне веба, но для консистентности
**Сообщение в Telegram** — текст: заголовок и подзаголовок события. Под ним две кнопки:
«Помню» и «Сделано» (или подпись источника). После «Сделано» кнопки под сообщением
убираются. Каждое новое напоминание — новое сообщение, старые не редактируются.
Ответы бота на нажатия: «Ок, напомню позже», «Закрыто», «Уже неактуально».
**Умный дом (webhook)** получает те же поля: заголовок, подзаголовок, источник, срок,
подпись кнопки «Сделано» и две ссылки-действия. Как это озвучивается или показывается —
на стороне умного дома.
## Чего здесь нет намеренно
- Истории закрытых событий: закрыл — исчезло, архива нет.
- Ручного создания событий: их присылают только сервисы-источники.
- Откладывания «на дату»: этим занимается правило «Помню».
- Настройки частоты напоминаний: она общая и не настраивается.
- Мобильного приложения: iOS с бейджем, виджетом «Грядёт» и Live Activity — отдельный
проект, его описание будет отдельно.

View File

@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#070912;--ink-0:#090B16;--ink-1:#11152E;--ink-2:#1B2148;--ink-3:#2B3566;--sakura:#F7CEDC;--nadeshiko:#EA8FAE;--tsutsuji:#E84E8A;--kobai:#C13E6A;--yamabuki:#F2B233;--hanada:#1F7FC4;--ruri:#355CD6;--byakugun:#8FD6DC;--fuji:#9F8FCB;--washi:#F2ECE0;--kasumi:#AEB4D6;--nibi:#6B7099;--hair:rgba(174,180,214,0.16);--hair-2:rgba(174,180,214,0.30)}
*{box-sizing:border-box}html,body{margin:0;padding:0;background:#070912}
::selection{background:rgba(143,214,220,.25)}
a{color:var(--byakugun);text-decoration-color:rgba(143,214,220,.4)}a:hover{color:var(--washi)}
input,select,button{font:inherit}
button[disabled]{opacity:.45;pointer-events:none}
.skdot:hover + .sklbl{opacity:1}
@keyframes skTwinkle{0%,100%{opacity:.15}50%{opacity:.75}}
@keyframes skNebula{0%,100%{transform:rotate(-14deg) translateX(-2.5%)}50%{transform:rotate(-14deg) translateX(2.5%)}}
@keyframes skNebulaCore{0%,100%{transform:rotate(-14deg) translateX(3%)}50%{transform:rotate(-14deg) translateX(-3%)}}
@keyframes skOrbXa{0%,100%{transform:translateX(36px)}50%{transform:translateX(-36px)}}
@keyframes skOrbYa{0%,100%{transform:translateY(13px)}50%{transform:translateY(-13px)}}
@keyframes skOrbXb{0%,100%{transform:translateX(24px)}50%{transform:translateX(-24px)}}
@keyframes skOrbYb{0%,100%{transform:translateY(22px)}50%{transform:translateY(-22px)}}
@keyframes skOrbXc{0%,100%{transform:translateX(14px)}50%{transform:translateX(-14px)}}
@keyframes skOrbYc{0%,100%{transform:translateY(28px)}50%{transform:translateY(-28px)}}
@keyframes skOrbXd{0%,100%{transform:translateX(-20px)}50%{transform:translateX(20px)}}
@keyframes skOrbYd{0%,100%{transform:translateY(16px)}50%{transform:translateY(-16px)}}
@keyframes skPulseDot{0%,100%{box-shadow:0 0 6px rgba(53,92,214,.5)}50%{box-shadow:0 0 14px rgba(53,92,214,.95)}}
</style>
</helmet>
<div style="min-height:100vh;background:var(--bg);color:var(--washi);font-family:'Space Grotesk',sans-serif;position:relative;overflow:hidden;display:flex;flex-direction:column">
<!-- атмосфера -->
<div ref="{{ starsRef }}" style="position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0"></div>
<div style="position:absolute;pointer-events:none;z-index:0;top:26%;left:-22%;width:150%;height:52%;filter:blur(34px);opacity:.72;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 42% 58% at 20% 60%, rgba(234,143,174,.20), transparent 70%),radial-gradient(ellipse 58% 46% at 48% 44%, rgba(232,78,138,.14), transparent 72%),radial-gradient(ellipse 38% 54% at 70% 57%, rgba(159,143,203,.17), transparent 70%),radial-gradient(ellipse 28% 40% at 88% 51%, rgba(143,214,220,.11), transparent 70%),radial-gradient(ellipse 52% 34% at 36% 62%, rgba(53,92,214,.11), transparent 72%)"></div>
<div style="position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(14px);opacity:.7;animation:skNebulaCore 58s ease-in-out infinite;background:radial-gradient(ellipse 20% 70% at 8% 71%, rgba(247,206,220,.22), transparent 70%),radial-gradient(ellipse 14% 90% at 22% 52%, rgba(242,236,224,.18), transparent 70%),radial-gradient(ellipse 18% 80% at 36% 48%, rgba(234,143,174,.22), transparent 72%),radial-gradient(ellipse 12% 100% at 48% 38%, rgba(247,206,220,.24), transparent 70%),radial-gradient(ellipse 16% 85% at 60% 43%, rgba(242,236,224,.17), transparent 72%),radial-gradient(ellipse 13% 75% at 72% 43%, rgba(234,143,174,.20), transparent 70%),radial-gradient(ellipse 18% 90% at 86% 55%, rgba(247,206,220,.18), transparent 72%),radial-gradient(ellipse 10% 60% at 94% 61%, rgba(143,214,220,.12), transparent 70%)"></div>
<div style="position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(10px);opacity:.8;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 20% 38% at 16% 72%, rgba(7,9,18,.6), transparent 75%),radial-gradient(ellipse 28% 34% at 44% 55%, rgba(9,11,22,.55), transparent 75%),radial-gradient(ellipse 18% 38% at 70% 50%, rgba(7,9,18,.55), transparent 75%),radial-gradient(ellipse 14% 30% at 90% 68%, rgba(9,11,22,.5), transparent 75%)"></div>
<div ref="{{ bandRef }}" style="position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;transform:rotate(-14deg)"></div>
<!-- сестринские планеты: все, кроме Hado -->
<div style="position:absolute;inset:0;pointer-events:none;z-index:0">
<div style="position:absolute;top:17%;left:7%">
<div style="animation:skOrbXa 19s ease-in-out infinite">
<div style="display:flex;flex-direction:column;align-items:center;gap:9px;animation:skOrbYa 19s ease-in-out -4.75s infinite">
<div class="skdot" style="pointer-events:auto;width:15px;height:15px;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--sakura), var(--nadeshiko) 50%, var(--ink-0) 92%);box-shadow:0 0 10px rgba(234,143,174,.4)"></div>
<span class="sklbl" style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s">YUME</span>
</div>
</div>
</div>
<div style="position:absolute;top:26%;right:8%">
<div style="animation:skOrbXb 27s ease-in-out infinite">
<div style="display:flex;flex-direction:column;align-items:center;gap:9px;animation:skOrbYb 27s ease-in-out -20.25s infinite">
<div class="skdot" style="pointer-events:auto;width:12px;height:12px;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--tsutsuji), var(--kobai) 52%, var(--ink-0) 92%);box-shadow:0 0 9px rgba(232,78,138,.4)"></div>
<span class="sklbl" style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s">MYAKU</span>
</div>
</div>
</div>
<div style="position:absolute;top:60%;left:4.5%">
<div style="animation:skOrbXc 14s ease-in-out infinite">
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;animation:skOrbYc 14s ease-in-out -3.5s infinite">
<div class="skdot" style="pointer-events:auto;width:9px;height:9px;border-radius:50%;background:radial-gradient(circle at 34% 30%, var(--fuji), #4A4177 55%, var(--ink-0) 92%);box-shadow:0 0 8px rgba(159,143,203,.4)"></div>
<span class="sklbl" style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s">KIOKU</span>
</div>
</div>
</div>
<div style="position:absolute;top:64%;right:6%">
<div style="animation:skOrbXd 22s ease-in-out infinite">
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;animation:skOrbYd 22s ease-in-out -5.5s infinite">
<div class="skdot" style="pointer-events:auto;width:10px;height:10px;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--byakugun), var(--hanada) 50%, var(--ink-0) 93%);box-shadow:0 0 9px rgba(143,214,220,.4)"></div>
<span class="sklbl" style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s">FUMI</span>
</div>
</div>
</div>
</div>
<header style="position:relative;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;padding:30px 44px 0 44px">
<div style="display:flex;align-items:baseline;gap:14px">
<span style="font-family:'Shippori Mincho',serif;font-size:54px;line-height:1;color:var(--washi)">波動</span>
<span style="font-family:'Space Mono',monospace;font-size:13px;letter-spacing:.5em;color:var(--nibi)">HADO</span>
<sc-if value="{{ hasCounter }}" hint-placeholder-val="{{ true }}">
<span style="display:inline-flex;align-items:center;gap:8px;padding:5px 12px 5px 10px;border:1px solid rgba(53,92,214,.55);border-radius:999px;background:rgba(53,92,214,.14)">
<span style="width:11px;height:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--ruri), #24337F 55%, var(--ink-0) 92%);animation:skPulseDot 2.6s ease-in-out infinite"></span>
<span style="font-family:'Space Mono',monospace;font-size:12px;color:var(--byakugun)">{{ counter }}</span>
</span>
</sc-if>
</div>
<div style="display:flex;align-items:center;gap:16px;margin-top:14px">
<button onClick="{{ toggleAdd }}" style="background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun);border-radius:8px;padding:8px 18px;font-size:13px;cursor:pointer;pointer-events:auto" style-hover="background:rgba(53,92,214,.4)">+ Добавить</button>
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)">05</span>
</div>
</header>
<main style="position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:72px 32px 100px 32px;flex:1">
<sc-if value="{{ addOpen }}">
<section style="margin-bottom:52px;border:1px dashed var(--hair-2);border-radius:14px;background:rgba(17,21,46,.7);padding:22px">
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">НОВОЕ СОБЫТИЕ</div>
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end">
<label style="display:flex;flex-direction:column;gap:6px;flex:2;min-width:220px">
<span style="font-size:12px;color:var(--nibi)">Заголовок</span>
<input value="{{ nTitle }}" onChange="{{ onNTitle }}" placeholder="Продлить страховку" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
</label>
<label style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:160px">
<span style="font-size:12px;color:var(--nibi)">Подзаголовок (опц.)</span>
<input value="{{ nSub }}" onChange="{{ onNSub }}" placeholder="раз в год" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
</label>
<label style="display:flex;flex-direction:column;gap:6px">
<span style="font-size:12px;color:var(--nibi)">Срок</span>
<input type="date" value="{{ nDate }}" onChange="{{ onNDate }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:8px 12px;font-size:14px">
</label>
<label style="display:flex;flex-direction:column;gap:6px">
<span style="font-size:12px;color:var(--nibi)">Время (опц.)</span>
<input type="time" value="{{ nTime }}" onChange="{{ onNTime }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:8px 12px;font-size:14px">
</label>
<button onClick="{{ addEvent }}" style="background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun);border-radius:8px;padding:9px 18px;font-size:13px;cursor:pointer" style-hover="background:rgba(53,92,214,.4)">Создать</button>
<button onClick="{{ toggleAdd }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:9px 14px;font-size:13px;cursor:pointer" style-hover="border-color:var(--byakugun);color:var(--byakugun)">Отмена</button>
</div>
<sc-if value="{{ nErr }}">
<div style="color:var(--sakura);font-size:12px;margin-top:12px">{{ nErr }}</div>
</sc-if>
</section>
</sc-if>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="4">
<section style="margin-bottom:52px">
<div style="display:flex;align-items:baseline;gap:12px;margin-bottom:18px">
<span style="font-family:'Shippori Mincho',serif;font-size:20px;color:var(--washi)">{{ g.jp }}</span>
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.35em;color:var(--kasumi)">{{ g.ru }}</span>
<span style="flex:1;border-bottom:1px solid var(--hair)"></span>
</div>
<sc-if value="{{ g.empty }}">
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi);padding:12px 2px">ПУСТО</div>
</sc-if>
<div style="display:flex;flex-direction:column;gap:12px">
<sc-for list="{{ g.events }}" as="ev" hint-placeholder-count="1">
<article style="background:rgba(27,33,72,.72);border:1px solid var(--hair);border-radius:12px;padding:16px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap">
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;min-width:64px;padding:8px 10px;border:1px solid var(--hair-2);border-radius:10px;background:var(--ink-1)">
<span style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.2em;color:var(--nibi)">{{ ev.dueMonth }}</span>
<span style="font-family:'Shippori Mincho',serif;font-size:24px;line-height:1;color:var(--washi)">{{ ev.dueDay }}</span>
<sc-if value="{{ ev.dueTime }}">
<span style="font-family:'Space Mono',monospace;font-size:10px;color:var(--byakugun)">{{ ev.dueTime }}</span>
</sc-if>
</div>
<div style="flex:1;min-width:220px;display:flex;flex-direction:column;gap:5px">
<sc-if value="{{ ev.link }}" hint-placeholder-val="{{ true }}">
<a href="{{ ev.link }}" style="align-self:flex-start;font-size:16px;font-weight:500;color:var(--washi);text-decoration:underline;text-decoration-style:dotted;text-decoration-color:var(--hair-2);text-underline-offset:4px" style-hover="color:var(--byakugun)">{{ ev.title }}</a>
</sc-if>
<sc-if value="{{ ev.noLink }}">
<span style="font-size:16px;font-weight:500;color:var(--washi)">{{ ev.title }}</span>
</sc-if>
<sc-if value="{{ ev.subtitle }}">
<span style="font-size:13px;color:var(--kasumi)">{{ ev.subtitle }}</span>
</sc-if>
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi);margin-top:3px">{{ ev.source }} · срок {{ ev.due }}<sc-if value="{{ ev.rel }}"> · <span style="color:var(--kasumi)">{{ ev.rel }}</span></sc-if></span>
</div>
<div style="display:flex;gap:10px;align-items:center">
<sc-if value="{{ ev.canRemind }}" hint-placeholder-val="{{ true }}">
<button disabled="{{ ev.busy }}" onClick="{{ ev.onRemind }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;transition:border-color .2s,color .2s" style-hover="border-color:var(--byakugun);color:var(--byakugun)">Помню</button>
</sc-if>
<button disabled="{{ ev.busy }}" onClick="{{ ev.onDone }}" style="background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun);border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;transition:background .2s" style-hover="background:rgba(53,92,214,.4)">{{ ev.doneLabel }}</button>
</div>
</article>
</sc-for>
</div>
</section>
</sc-for>
<!-- архив -->
<section style="margin-top:64px;border:1px solid var(--hair);border-radius:14px;background:rgba(17,21,46,.7);overflow:hidden">
<button onClick="{{ toggleArchive }}" style="width:100%;display:flex;align-items:center;gap:14px;background:none;border:none;padding:18px 22px;cursor:pointer;text-align:left;color:var(--washi)">
<span style="font-family:'Shippori Mincho',serif;font-size:16px">書庫</span>
<span style="font-size:14px">Архив</span>
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)">{{ archiveCount }} закрыто</span>
<span style="margin-left:auto;font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi)">{{ archiveChevron }}</span>
</button>
<sc-if value="{{ archiveOpen }}">
<div style="border-top:1px solid var(--hair);padding:18px 22px;display:flex;flex-direction:column;gap:10px">
<sc-if value="{{ archiveEmpty }}">
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)">ПУСТО</div>
</sc-if>
<sc-for list="{{ archive }}" as="ar" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1);opacity:.75">
<span style="font-size:14px;color:var(--kasumi);flex:1;min-width:180px">{{ ar.title }}</span>
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi)">{{ ar.source }} · {{ ar.due }}</span>
<span style="font-family:'Space Mono',monospace;font-size:10px;letter-spacing:.15em;color:var(--fuji);border:1px solid var(--hair);border-radius:999px;padding:3px 10px">{{ ar.closedAs }}</span>
</div>
</sc-for>
</div>
</sc-if>
</section>
<!-- настройки -->
<section style="margin-top:64px;border:1px solid var(--hair);border-radius:14px;background:rgba(17,21,46,.7);overflow:hidden">
<button onClick="{{ toggleSettings }}" style="width:100%;display:flex;align-items:center;gap:14px;background:none;border:none;padding:18px 22px;cursor:pointer;text-align:left;color:var(--washi)">
<span style="font-family:'Shippori Mincho',serif;font-size:16px">設定</span>
<span style="font-size:14px">Настройки</span>
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)">m.orlov</span>
<span style="margin-left:auto;font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi)">{{ settingsChevron }}</span>
</button>
<sc-if value="{{ settingsOpen }}">
<div style="border-top:1px solid var(--hair);padding:26px 22px 30px 22px;display:flex;flex-direction:column;gap:34px">
<div>
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">ПОЯС И ТИХИЕ ЧАСЫ</div>
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end">
<label style="display:flex;flex-direction:column;gap:6px;min-width:240px">
<span style="font-size:12px;color:var(--nibi)">Часовой пояс</span>
<select value="{{ tz }}" onChange="{{ onTz }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
<sc-for list="{{ tzList }}" as="z" hint-placeholder-count="3">
<option value="{{ z }}">{{ z }}</option>
</sc-for>
</select>
</label>
<label style="display:flex;flex-direction:column;gap:6px">
<span style="font-size:12px;color:var(--nibi)">Тихие часы, с</span>
<input type="time" value="{{ qFrom }}" onChange="{{ onQFrom }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:8px 12px;font-size:14px">
</label>
<label style="display:flex;flex-direction:column;gap:6px">
<span style="font-size:12px;color:var(--nibi)">до</span>
<input type="time" value="{{ qTo }}" onChange="{{ onQTo }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:8px 12px;font-size:14px">
</label>
<button onClick="{{ saveQuiet }}" style="background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun);border-radius:8px;padding:9px 18px;font-size:13px;cursor:pointer" style-hover="background:rgba(53,92,214,.4)">Сохранить</button>
</div>
<sc-if value="{{ qErr }}">
<div style="color:var(--sakura);font-size:12px;margin-top:12px">{{ qErr }}</div>
</sc-if>
<sc-if value="{{ qSaved }}">
<div style="color:var(--byakugun);font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;margin-top:12px">Сохранено — напоминания сдвинуты под пояс</div>
</sc-if>
</div>
<div>
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">КАНАЛЫ</div>
<div style="display:flex;flex-direction:column;gap:10px">
<sc-for list="{{ channels }}" as="ch" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1)">
<span style="font-size:14px;color:var(--washi);min-width:76px">{{ ch.name }}</span>
<span style="font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi);flex:1;overflow-wrap:anywhere">{{ ch.note }}</span>
<sc-if value="{{ ch.removable }}">
<button onClick="{{ ch.onDelete }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:6px 12px;font-size:12px;cursor:pointer" style-hover="border-color:var(--nadeshiko);color:var(--sakura)">Удалить</button>
</sc-if>
</div>
</sc-for>
</div>
</div>
<div>
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">ДОБАВИТЬ WEBHOOK</div>
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end">
<label style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:220px">
<span style="font-size:12px;color:var(--nibi)">Адрес доставки</span>
<input type="url" value="{{ whUrl }}" onChange="{{ onWhUrl }}" placeholder="https://home.local/hado/notify" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
</label>
<label style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:220px">
<span style="font-size:12px;color:var(--nibi)">Адрес присутствия</span>
<input type="url" value="{{ whPresence }}" onChange="{{ onWhPresence }}" placeholder="https://home.local/presence" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
</label>
<button onClick="{{ addWebhook }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:9px 18px;font-size:13px;cursor:pointer" style-hover="border-color:var(--byakugun);color:var(--byakugun)">Добавить webhook</button>
</div>
<sc-if value="{{ whErr }}">
<div style="color:var(--sakura);font-size:12px;margin-top:12px">{{ whErr }}</div>
</sc-if>
</div>
<div>
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">TELEGRAM</div>
<sc-if value="{{ noTgCode }}" hint-placeholder-val="{{ true }}">
<button onClick="{{ connectTg }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:9px 18px;font-size:13px;cursor:pointer" style-hover="border-color:var(--byakugun);color:var(--byakugun)">Подключить Telegram</button>
</sc-if>
<sc-if value="{{ tgCode }}">
<div style="border:1px dashed var(--hair-2);border-radius:10px;padding:14px 18px;display:flex;flex-direction:column;gap:8px">
<span style="font-size:14px;color:var(--washi)">Отправь боту <span style="font-family:'Space Mono',monospace;color:var(--byakugun)">/start {{ tgCode }}</span> или <a href="#tg">открой</a></span>
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)">Код живёт 15 минут. Канал появится в списке при следующем открытии страницы.</span>
</div>
</sc-if>
</div>
</div>
</sc-if>
</section>
</main>
<footer style="position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:22px 32px 36px 32px;border-top:1px solid var(--hair);display:flex;justify-content:center;gap:8px;font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)">
<span>波動 HADO</span>
<span>· 世界 SEKAI</span>
</footer>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;showEmpty&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Инбокс&quot;},&quot;telegramLinked&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Каналы&quot;}}">
class Component extends DCLogic {
constructor(props) {
super(props);
this.starsRef = React.createRef();
this.bandRef = React.createRef();
const mk = (id, group, title, subtitle, source, due, link, doneLabel) => ({ id, group, title, subtitle, source, due, link, doneLabel: doneLabel || 'Сделано' });
this.state = {
events: [
mk('e1', 'today', 'Сегодня ДР — Вася', 'исполняется 34', 'people', '2026-09-04', '#people/vasya', 'Поздравил'),
mk('e2', 'today', 'Вылет GOL 1483 — Монтевидео → Сан-Пауло', null, 'docs', '2026-09-04 19:40', '#docs/gol1483', null),
mk('e3', 'overdue', 'Аренда за сентябрь не оплачена', 'срок прошёл 3 дня назад', 'payments', '2026-09-01', null, 'Оплатил'),
mk('e4', 'soon', 'Седула истекает', 'осталось 26 дней', 'docs', '2026-09-30', '#docs/cedula', 'Продлил'),
mk('e5', 'soon', 'Вылет SU 401 — Стамбул', null, 'docs', '2026-09-10 08:15', '#docs/su401', null),
mk('e6', 'planned', 'ДР — Мама', null, 'people', '2026-11-02', '#people/mama', 'Поздравил'),
mk('e7', 'planned', 'Продление домена hado.uy', null, 'docs', '2026-12-15', null, 'Продлил')
],
busy: {}, settingsOpen: false,
tz: 'America/Montevideo', qFrom: '22:00', qTo: '09:00', qErr: '', qSaved: false,
channels: [
{ id: 'web', name: 'Веб', note: 'этот инбокс', removable: false },
{ id: 'tg', name: 'Telegram', note: 'чат 88 214 302', removable: true },
{ id: 'wh1', name: 'Webhook', note: 'https://home.orlov.uy/hado/notify', removable: true }
],
whUrl: '', whPresence: '', whErr: '', tgCode: null,
archive: [
{ id: 'a1', title: 'ДР — Лена', source: 'people', due: '2026-08-28', closedAs: 'Поздравил' },
{ id: 'a2', title: 'Аренда за август', source: 'payments', due: '2026-08-01', closedAs: 'Оплатил' },
{ id: 'a3', title: 'Виза — подача документов', source: 'docs', due: '2026-07-14', closedAs: 'Сделано' }
],
archiveOpen: false,
addOpen: false, nTitle: '', nSub: '', nDate: '', nTime: '', nErr: ''
};
try { this.tzList = Intl.supportedValuesOf('timeZone'); }
catch (e) { this.tzList = ['America/Montevideo', 'Asia/Shanghai', 'Europe/Belgrade', 'UTC']; }
}
counter() { return this.state.events.filter(e => e.group === 'today' || e.group === 'overdue').length; }
syncTitle() { const c = this.counter(); document.title = c ? '(' + c + ') Hado' : 'Hado'; }
componentDidMount() { this.syncTitle(); this.spawnStars(); }
componentDidUpdate() { this.syncTitle(); }
spawnStars() {
const star = (color, size, bright, x, y, dur, delay) => {
const s = document.createElement('div');
s.style.cssText = 'position:absolute;border-radius:50%;left:' + x + '%;top:' + y + '%;width:' + size + 'px;height:' + size + 'px;background:' + color + ';' + (bright ? 'box-shadow:0 0 6px ' + color + ';' : '') + 'animation:skTwinkle ' + dur + 's ease-in-out ' + delay + 's infinite';
return s;
};
const sky = this.starsRef.current, band = this.bandRef.current;
if (!sky || !band || sky.childElementCount > 0) return;
for (let i = 0; i < 90; i++) {
sky.appendChild(star(Math.random() < 0.2 ? '#8FD6DC' : '#F2ECE0', Math.random() < 0.85 ? 1.5 : 2.5, false,
(Math.random() * 100).toFixed(2), (Math.random() * 100).toFixed(2), (3 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
}
for (let j = 0; j < 170; j++) {
const bright = Math.random() < 0.08, r = Math.random();
const color = r < 0.68 ? '#F2ECE0' : (r < 0.88 ? '#F7CEDC' : '#8FD6DC');
const g = (Math.random() + Math.random() + Math.random()) / 3;
const x = Math.random() * 100;
const arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * 0.35;
band.appendChild(star(color, bright ? 2.2 : (0.8 + Math.random() * 1.1).toFixed(1), bright,
x.toFixed(2), (6 + g * 55 + arc).toFixed(2), (2.5 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
}
}
act(id, remove) {
this.setState(s => ({ busy: { ...s.busy, [id]: true } }));
setTimeout(() => {
this.setState(s => {
const ev = s.events.find(e => e.id === id);
return {
busy: { ...s.busy, [id]: false },
events: remove ? s.events.filter(e => e.id !== id) : s.events,
archive: remove && ev ? [{ id: 'a' + id, title: ev.title, source: ev.source, due: ev.due, closedAs: ev.doneLabel }, ...s.archive] : s.archive
};
});
}, 700);
}
dueParts(due) {
const m = due.match(/^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}:\d{2}))?$/);
if (!m) return { dueMonth: '', dueDay: due, dueTime: null, rel: null };
const months = ['ЯНВ', 'ФЕВ', 'МАР', 'АПР', 'МАЙ', 'ИЮН', 'ИЮЛ', 'АВГ', 'СЕН', 'ОКТ', 'НОЯ', 'ДЕК'];
const today = new Date(); today.setHours(0, 0, 0, 0);
const d = new Date(+m[1], +m[2] - 1, +m[3]);
const diff = Math.round((d - today) / 86400000);
const px = n => n % 10 === 1 && n % 100 !== 11 ? 'день' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 'дня' : 'дней');
let rel = null;
if (diff === 0) rel = 'сегодня';
else if (diff === 1) rel = 'завтра';
else if (diff > 1) rel = 'через ' + diff + ' ' + px(diff);
else if (diff === -1) rel = 'вчера';
else rel = Math.abs(diff) + ' ' + px(Math.abs(diff)) + ' назад';
return { dueMonth: months[+m[2] - 1], dueDay: m[3], dueTime: m[4] || null, rel };
}
renderVals() {
const st = this.state;
const showEmpty = this.props.showEmpty ?? true;
const tgLinked = this.props.telegramLinked ?? true;
const deco = list => list.slice().sort((a, b) => (a.due < b.due ? -1 : 1)).map(e => ({
...e, ...this.dueParts(e.due), noLink: !e.link, busy: !!st.busy[e.id], canRemind: e.group !== 'planned',
onRemind: () => this.act(e.id, false), onDone: () => this.act(e.id, true)
}));
const defs = [['today', '今日', 'СЕГОДНЯ'], ['overdue', '遅延', 'ПРОСРОЧЕНО'], ['soon', '近日', 'ГРЯДЁТ'], ['planned', '予定', 'ЗАПЛАНИРОВАНО']];
const groups = defs.map(([key, jp, ru]) => {
const events = deco(st.events.filter(e => e.group === key));
return { key, jp, ru, events, empty: events.length === 0 };
}).filter(g => !g.empty || showEmpty);
const c = this.counter();
return {
groups, counter: c, hasCounter: c > 0,
starsRef: this.starsRef, bandRef: this.bandRef,
settingsOpen: st.settingsOpen, settingsChevron: st.settingsOpen ? '▲' : '▼',
toggleSettings: () => this.setState(s => ({ settingsOpen: !s.settingsOpen })),
tz: st.tz, tzList: this.tzList, onTz: e => this.setState({ tz: e.target.value }),
qFrom: st.qFrom, qTo: st.qTo, qErr: st.qErr, qSaved: st.qSaved,
onQFrom: e => this.setState({ qFrom: e.target.value, qSaved: false }),
onQTo: e => this.setState({ qTo: e.target.value, qSaved: false }),
saveQuiet: () => {
if (this.state.qFrom <= this.state.qTo) {
this.setState({ qErr: 'Интервал обязан переходить через полночь: «с 22:00 до 09:00» — верно, «с 08:00 до 22:00» — ошибка.', qSaved: false });
} else {
this.setState({ qErr: '', qSaved: true });
clearTimeout(this._qt);
this._qt = setTimeout(() => this.setState({ qSaved: false }), 3000);
}
},
channels: st.channels.filter(ch => ch.id !== 'tg' || tgLinked).map(ch => ({
...ch, onDelete: () => this.setState(s => ({ channels: s.channels.filter(c2 => c2.id !== ch.id) }))
})),
whUrl: st.whUrl, whPresence: st.whPresence, whErr: st.whErr,
onWhUrl: e => this.setState({ whUrl: e.target.value }),
onWhPresence: e => this.setState({ whPresence: e.target.value }),
addWebhook: () => {
const ok = u => { try { const x = new URL(u); return x.protocol === 'http:' || x.protocol === 'https:'; } catch (e) { return false; } };
const { whUrl, whPresence } = this.state;
if (!ok(whUrl) || !ok(whPresence)) { this.setState({ whErr: 'Оба поля обязательны и должны быть URL (http/https).' }); return; }
this.setState(s => ({ channels: [...s.channels, { id: 'wh' + Date.now(), name: 'Webhook', note: whUrl, removable: true }], whUrl: '', whPresence: '', whErr: '' }));
},
archive: st.archive, archiveOpen: st.archiveOpen, archiveEmpty: st.archive.length === 0,
archiveCount: st.archive.length, archiveChevron: st.archiveOpen ? '▲' : '▼',
toggleArchive: () => this.setState(s => ({ archiveOpen: !s.archiveOpen })),
addOpen: st.addOpen, nTitle: st.nTitle, nSub: st.nSub, nDate: st.nDate, nTime: st.nTime, nErr: st.nErr,
toggleAdd: () => this.setState(s => ({ addOpen: !s.addOpen, nErr: '' })),
onNTitle: e => this.setState({ nTitle: e.target.value }),
onNSub: e => this.setState({ nSub: e.target.value }),
onNDate: e => this.setState({ nDate: e.target.value }),
onNTime: e => this.setState({ nTime: e.target.value }),
addEvent: () => {
const { nTitle, nSub, nDate, nTime } = this.state;
if (!nTitle.trim() || !nDate) { this.setState({ nErr: 'Заголовок и срок обязательны.' }); return; }
const today = new Date(); today.setHours(0, 0, 0, 0);
const [y, mo, da] = nDate.split('-').map(Number);
const diff = Math.round((new Date(y, mo - 1, da) - today) / 86400000);
const group = diff < 0 ? 'overdue' : (diff === 0 ? 'today' : (diff <= 30 ? 'soon' : 'planned'));
this.setState(s => ({
events: [...s.events, { id: 'u' + Date.now(), group, title: nTitle.trim(), subtitle: nSub.trim() || null, source: 'вручную', due: nDate + (nTime ? ' ' + nTime : ''), link: null, doneLabel: 'Сделано' }],
addOpen: false, nTitle: '', nSub: '', nDate: '', nTime: '', nErr: ''
}));
},
tgCode: st.tgCode, noTgCode: !st.tgCode,
connectTg: () => {
const abc = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
this.setState({ tgCode: Array.from({ length: 6 }, () => abc[Math.floor(Math.random() * abc.length)]).join('') });
}
};
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Mono&display=swap" rel="stylesheet">
<style>
:root{--bg:#070912;--ink-0:#090B16;--ink-1:#11152E;--ink-2:#1B2148;--ink-3:#2B3566;--sakura:#F7CEDC;--nadeshiko:#EA8FAE;--tsutsuji:#E84E8A;--kobai:#C13E6A;--yamabuki:#F2B233;--hanada:#1F7FC4;--ruri:#355CD6;--byakugun:#8FD6DC;--fuji:#9F8FCB;--washi:#F2ECE0;--kasumi:#AEB4D6;--nibi:#6B7099;--hair:rgba(174,180,214,0.16);--hair-2:rgba(174,180,214,0.30)}
*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg)}
a{color:var(--byakugun)}a:hover{color:var(--washi)}
@keyframes skFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-18px)}}
@keyframes skSpin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
@keyframes skSpinBack{from{transform:rotate(360deg)}to{transform:rotate(0deg)}}
@keyframes skPulse{0%,100%{opacity:.35;transform:scale(1)}50%{opacity:.85;transform:scale(1.06)}}
@keyframes skEcho{0%{transform:scale(.55);opacity:.7}100%{transform:scale(1.5);opacity:0}}
</style>
</helmet>
<div style="min-height:100vh;background:var(--bg);display:flex;align-items:center;justify-content:center;padding:60px 24px;font-family:'Space Mono',monospace">
<div style="border:1px solid var(--hair);border-radius:16px;background:var(--ink-1);padding:48px 32px 32px 32px;display:flex;flex-direction:column;align-items:center;gap:40px;max-width:460px;width:100%">
<div style="width:260px;height:260px;position:relative">
<div style="position:absolute;inset:-15%;border:1px solid var(--hair);border-radius:50%;transform:rotateX(69deg)"></div>
<div style="position:absolute;inset:-27%;border:1px dashed var(--hair);border-radius:50%;transform:rotateX(69deg) rotateZ(-8deg);animation:skSpinBack 48s linear infinite"></div>
<!-- эхо: волна напоминания расходится и возвращается -->
<div style="position:absolute;inset:-6%;border:1px solid rgba(143,214,220,.45);border-radius:50%;animation:skEcho 4.2s ease-out infinite"></div>
<div style="position:absolute;inset:-6%;border:1px solid rgba(53,92,214,.5);border-radius:50%;animation:skEcho 4.2s ease-out 2.1s infinite"></div>
<div style="width:100%;height:100%;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--ruri), #24337F 55%, var(--ink-0) 92%);box-shadow:0 0 70px rgba(53,92,214,.35), inset -22px -26px 60px rgba(9,11,22,.85);animation:skFloat 7.5s ease-in-out infinite"></div>
<div style="position:absolute;inset:0;border-radius:50%;pointer-events:none;box-shadow:0 0 46px rgba(53,92,214,.4);animation:skPulse 2.6s ease-in-out infinite"></div>
<!-- спутник 1: сигнал -->
<div style="position:absolute;inset:-15%;transform:rotateX(69deg);pointer-events:none">
<div style="width:100%;height:100%;animation:skSpin 13s linear infinite">
<div style="position:absolute;top:0;left:50%;width:10px;height:10px;border-radius:50%;background:var(--byakugun);box-shadow:0 0 14px rgba(143,214,220,.75);transform:translate(-50%,-50%) rotateX(-69deg)"></div>
</div>
</div>
<!-- спутник 2: эхо возвращается по внешней орбите -->
<div style="position:absolute;inset:-27%;transform:rotateX(69deg) rotateZ(-8deg);pointer-events:none">
<div style="width:100%;height:100%;animation:skSpinBack 30s linear infinite">
<div style="position:absolute;top:0;left:50%;width:7px;height:7px;border-radius:50%;background:var(--ruri);box-shadow:0 0 10px rgba(53,92,214,.8);transform:translate(-50%,-50%) rotateX(-69deg)"></div>
</div>
</div>
</div>
<div style="text-align:center">
<div style="font-family:'Shippori Mincho',serif;font-size:34px;color:var(--washi)">波動</div>
<div style="font-size:12px;letter-spacing:.4em;color:var(--kasumi);margin-top:6px">HADO · НАПОМИНАНИЯ</div>
<div style="font-size:11px;letter-spacing:.2em;color:var(--nibi);margin-top:10px">05 · 波動共鳴 · СПУТНИКИ: СИГНАЛ --byakugun + ЭХО --ruri</div>
<div style="display:flex;align-items:center;justify-content:center;gap:10px;margin-top:16px">
<span style="width:11px;height:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--ruri), #24337F 55%, var(--ink-0) 92%);box-shadow:0 0 8px rgba(53,92,214,.45)"></span>
<span style="font-size:10px;letter-spacing:.25em;color:var(--nibi)">МИНИ-ВЕРСИЯ ДЛЯ НАВИГАЦИИ</span>
</div>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:560,&quot;height&quot;:640}}"></script>
</body>
</html>

View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sekai — 星雲 Nebula</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500&family=Space+Mono&display=swap" rel="stylesheet">
<style>
/* ═══ Sekai Nebula — самодостаточный компонент ═══
Вставляется целиком: <div class="sk-nebula"> + скрипт звёзд.
Слои: фоновые звёзды → туман → ядро (дуга) → пылевые разломы → звёзды полосы. */
*{box-sizing:border-box;margin:0;padding:0}
body{background:#070912;min-height:100vh}
.sk-nebula{position:relative;min-height:100vh;background:#070912;overflow:hidden}
.sk-nebula__layer{position:absolute;pointer-events:none}
/* базовый туман */
.sk-nebula__mist{top:26%;left:-22%;width:150%;height:52%;filter:blur(34px);opacity:.72;
animation:skNebula 46s ease-in-out infinite;
background:
radial-gradient(ellipse 42% 58% at 20% 60%, rgba(234,143,174,.20), transparent 70%),
radial-gradient(ellipse 58% 46% at 48% 44%, rgba(232,78,138,.14), transparent 72%),
radial-gradient(ellipse 38% 54% at 70% 57%, rgba(159,143,203,.17), transparent 70%),
radial-gradient(ellipse 28% 40% at 88% 51%, rgba(143,214,220,.11), transparent 70%),
radial-gradient(ellipse 52% 34% at 36% 62%, rgba(53,92,214,.11), transparent 72%)}
/* яркое ядро — сгустки по дуге */
.sk-nebula__core{top:44%;left:-15%;width:135%;height:14%;filter:blur(14px);opacity:.7;
animation:skNebulaCore 58s ease-in-out infinite;
background:
radial-gradient(ellipse 20% 70% at 8% 71%, rgba(247,206,220,.22), transparent 70%),
radial-gradient(ellipse 14% 90% at 22% 52%, rgba(242,236,224,.18), transparent 70%),
radial-gradient(ellipse 18% 80% at 36% 48%, rgba(234,143,174,.22), transparent 72%),
radial-gradient(ellipse 12% 100% at 48% 38%, rgba(247,206,220,.24), transparent 70%),
radial-gradient(ellipse 16% 85% at 60% 43%, rgba(242,236,224,.17), transparent 72%),
radial-gradient(ellipse 13% 75% at 72% 43%, rgba(234,143,174,.20), transparent 70%),
radial-gradient(ellipse 18% 90% at 86% 55%, rgba(247,206,220,.18), transparent 72%),
radial-gradient(ellipse 10% 60% at 94% 61%, rgba(143,214,220,.12), transparent 70%)}
/* тёмные пылевые разломы поверх ядра */
.sk-nebula__dust{top:44%;left:-15%;width:135%;height:14%;filter:blur(10px);opacity:.8;
animation:skNebula 46s ease-in-out infinite;
background:
radial-gradient(ellipse 20% 38% at 16% 72%, rgba(7,9,18,.6), transparent 75%),
radial-gradient(ellipse 28% 34% at 44% 55%, rgba(9,11,22,.55), transparent 75%),
radial-gradient(ellipse 18% 38% at 70% 50%, rgba(7,9,18,.55), transparent 75%),
radial-gradient(ellipse 14% 30% at 90% 68%, rgba(9,11,22,.5), transparent 75%)}
/* контейнеры звёзд (наполняются скриптом) */
.sk-nebula__stars{inset:0;overflow:hidden}
.sk-nebula__band{top:44%;left:-15%;width:135%;height:14%;transform:rotate(-14deg)}
.sk-nebula__caption{position:absolute;left:0;right:0;bottom:36px;display:flex;justify-content:center;padding:0 32px}
.sk-nebula__caption>div{max-width:640px;text-align:center}
.sk-nebula__caption .t{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.35em;color:#8FD6DC;margin-bottom:10px}
.sk-nebula__caption p{font-family:'Space Grotesk',sans-serif;font-size:13px;line-height:1.7;color:#6B7099}
@keyframes skTwinkle{0%,100%{opacity:.15}50%{opacity:.75}}
@keyframes skNebula{0%,100%{transform:rotate(-14deg) translateX(-2.5%)}50%{transform:rotate(-14deg) translateX(2.5%)}}
@keyframes skNebulaCore{0%,100%{transform:rotate(-14deg) translateX(3%)}50%{transform:rotate(-14deg) translateX(-3%)}}
</style>
</head>
<body>
<div class="sk-nebula">
<div class="sk-nebula__layer sk-nebula__stars" data-sk-stars></div>
<div class="sk-nebula__layer sk-nebula__mist"></div>
<div class="sk-nebula__layer sk-nebula__core"></div>
<div class="sk-nebula__layer sk-nebula__dust"></div>
<div class="sk-nebula__layer sk-nebula__band" data-sk-band></div>
<div class="sk-nebula__caption">
<div>
<div class="t">星雲 · SEKAI NEBULA</div>
<p>Млечная полоса мира Sekai — диск галактики, видимый с планеты Fumi с ребра, поэтому он ложится дугой через всё небо. Розовое свечение — цветение (сакура), редкие голубые вспышки — лёд и вода, а тёмные разрывы в ядре — пылевые рукава, скрывающие свет.</p>
</div>
</div>
</div>
<script>
(function () {
function star(color, size, bright, x, y, dur, delay) {
var s = document.createElement('div');
s.style.cssText = 'position:absolute;border-radius:50%;' +
'left:' + x + '%;top:' + y + '%;width:' + size + 'px;height:' + size + 'px;' +
'background:' + color + ';' +
(bright ? 'box-shadow:0 0 6px ' + color + ';' : '') +
'animation:skTwinkle ' + dur + 's ease-in-out ' + delay + 's infinite';
return s;
}
// фоновые звёзды
var sky = document.querySelector('[data-sk-stars]');
for (var i = 0; i < 90; i++) {
sky.appendChild(star(
Math.random() < 0.2 ? '#8FD6DC' : '#F2ECE0',
Math.random() < 0.85 ? 1.5 : 2.5, false,
(Math.random() * 100).toFixed(2), (Math.random() * 100).toFixed(2),
(3 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)
));
}
// россыпь внутри полосы: гауссово распределение вокруг дуги,
// дуга = парабола + дополнительный увод левого края вниз
var band = document.querySelector('[data-sk-band]');
for (var j = 0; j < 170; j++) {
var bright = Math.random() < 0.08;
var r = Math.random();
var color = r < 0.68 ? '#F2ECE0' : (r < 0.88 ? '#F7CEDC' : '#8FD6DC');
var g = (Math.random() + Math.random() + Math.random()) / 3;
var x = Math.random() * 100;
var arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * 0.35;
band.appendChild(star(
color,
bright ? 2.2 : (0.8 + Math.random() * 1.1).toFixed(1), bright,
x.toFixed(2), (6 + g * 55 + arc).toFixed(2),
(2.5 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)
));
}
})();
</script>
<template id="__bundler_thumbnail">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#070912"/><ellipse cx="50" cy="52" rx="34" ry="10" fill="#EA8FAE" opacity=".35" transform="rotate(-14 50 52)"/><ellipse cx="50" cy="50" rx="24" ry="4" fill="#F7CEDC" opacity=".6" transform="rotate(-14 50 50)"/><circle cx="30" cy="38" r="1.2" fill="#F2ECE0"/><circle cx="68" cy="60" r="1.2" fill="#F2ECE0"/><circle cx="60" cy="34" r="1" fill="#8FD6DC"/></svg>
</template>
</body>
</html>

View File

@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sekai — планеты (компоненты)</title>
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Mono&display=swap" rel="stylesheet">
<style>
/* Sekai tokens (standalone) */
:root{
--bg:#070912; --ink-0:#090B16; --ink-1:#11152E; --ink-2:#1B2148; --ink-3:#2B3566;
--sakura:#F7CEDC; --nadeshiko:#EA8FAE; --tsutsuji:#E84E8A; --kobai:#C13E6A; --yamabuki:#F2B233;
--hanada:#1F7FC4; --ruri:#355CD6; --byakugun:#8FD6DC; --fuji:#9F8FCB;
--washi:#F2ECE0; --kasumi:#AEB4D6; --nibi:#6B7099;
--hair:rgba(174,180,214,0.16); --hair-2:rgba(174,180,214,0.30);
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--washi);font-family:'Space Mono',monospace;min-height:100vh;padding:60px 40px}
@keyframes skFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-18px)}}
@keyframes skFloatSlow{0%,100%{transform:translateY(0)}50%{transform:translateY(-10px)}}
@keyframes skSpin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
@keyframes skSpinBack{from{transform:rotate(360deg)}to{transform:rotate(0deg)}}
@keyframes skPulse{0%,100%{opacity:.35;transform:scale(1)}50%{opacity:.8;transform:scale(1.06)}}
@keyframes skEcho{0%{transform:scale(.55);opacity:.7}100%{transform:scale(1.5);opacity:0}}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:40px;max-width:1240px;margin:0 auto}
.cell{border:1px solid var(--hair);border-radius:16px;background:var(--ink-1);padding:48px 24px 32px;display:flex;flex-direction:column;align-items:center;gap:40px}
.stage{width:260px;height:260px;position:relative}
.label{text-align:center}
.label .jp{font-family:'Shippori Mincho',serif;font-size:34px;color:var(--washi)}
.label .en{font-size:12px;letter-spacing:.4em;color:var(--kasumi);margin-top:6px}
.label .meta{font-size:11px;letter-spacing:.2em;color:var(--nibi);margin-top:10px}
/* общие части планеты */
.planet{width:100%;height:100%;border-radius:50%}
.ring{position:absolute;border:1px solid var(--hair);border-radius:50%}
.sat-orbit{position:absolute;pointer-events:none}
.sat-spin{width:100%;height:100%}
.sat{position:absolute;top:0;left:50%}
.glow{position:absolute;inset:0;border-radius:50%;pointer-events:none}
</style>
</head>
<body>
<div style="max-width:1240px;margin:0 auto 40px">
<div style="font-family:'Shippori Mincho',serif;font-size:28px">世界 · Планеты</div>
<div style="font-size:12px;letter-spacing:.2em;color:var(--nibi);margin-top:8px">5 ПЛАНЕТ ЭКОСИСТЕМЫ · КАЖДЫЙ БЛОК — АВТОНОМНЫЙ, КОПИРУЕТСЯ ЦЕЛИКОМ</div>
</div>
<div class="grid">
<!-- ═══════ 01 · MYAKU 脈 — финансы (tsutsuji/kobai + золотая монета) ═══════ -->
<div class="cell">
<div class="stage">
<div class="ring" style="inset:-14%;transform:rotateX(68deg)"></div>
<div class="ring" style="inset:-26%;border-style:dashed;transform:rotateX(68deg);animation:skSpinBack 44s linear infinite"></div>
<div class="planet" style="background:radial-gradient(circle at 32% 28%, var(--tsutsuji), var(--kobai) 52%, var(--ink-0) 92%);box-shadow:0 0 70px rgba(232,78,138,.30), inset -22px -26px 60px rgba(9,11,22,.85);animation:skFloat 7s ease-in-out infinite"></div>
<div class="glow" style="box-shadow:0 0 46px rgba(232,78,138,.35);animation:skPulse 3.2s ease-in-out infinite"></div>
<!-- спутник: золотая монета -->
<div class="sat-orbit" style="inset:-14%;transform:rotateX(68deg)">
<div class="sat-spin" style="animation:skSpin 12s linear infinite">
<div class="sat" style="width:12px;height:12px;border-radius:50%;background:var(--yamabuki);box-shadow:0 0 14px rgba(242,178,51,.7);transform:translate(-50%,-50%) rotateX(-68deg)"></div>
</div>
</div>
</div>
<div class="label">
<div class="jp"></div>
<div class="en">MYAKU · ФИНАНСЫ</div>
<div class="meta">01 · 脈拍安定 · СПУТНИК: МОНЕТА --yamabuki</div>
</div>
</div>
<!-- ═══════ 02 · KIOKU 記憶 — документы (fuji + кристалл) ═══════ -->
<div class="cell">
<div class="stage">
<div class="ring" style="inset:-18%;transform:rotateX(72deg) rotateZ(-12deg)"></div>
<div class="planet" style="background:radial-gradient(circle at 34% 30%, var(--fuji), #4A4177 55%, var(--ink-0) 92%);box-shadow:0 0 60px rgba(159,143,203,.25), inset -22px -26px 60px rgba(9,11,22,.85);animation:skFloatSlow 9s ease-in-out infinite"></div>
<!-- осколки памяти -->
<div style="position:absolute;top:6%;right:-6%;width:14px;height:14px;background:var(--fuji);opacity:.7;transform:rotate(45deg);animation:skFloat 6s ease-in-out infinite"></div>
<div style="position:absolute;bottom:14%;left:-9%;width:9px;height:9px;background:var(--fuji);opacity:.45;transform:rotate(45deg);animation:skFloatSlow 8s ease-in-out infinite"></div>
<!-- спутник: кристалл памяти -->
<div class="sat-orbit" style="inset:-18%;transform:rotateX(72deg) rotateZ(-12deg)">
<div class="sat-spin" style="animation:skSpinBack 20s linear infinite">
<div class="sat" style="width:9px;height:9px;background:var(--fuji);box-shadow:0 0 12px rgba(159,143,203,.7);transform:translate(-50%,-50%) rotateX(-72deg) rotate(45deg)"></div>
</div>
</div>
</div>
<div class="label">
<div class="jp">記憶</div>
<div class="en">KIOKU · ПАМЯТЬ</div>
<div class="meta">02 · 記憶保存 · СПУТНИК: КРИСТАЛЛ --fuji</div>
</div>
</div>
<!-- ═══════ 03 · YUME 夢 — манга (sakura/nadeshiko + лепесток) ═══════ -->
<div class="cell">
<div class="stage">
<div class="ring" style="inset:-16%;transform:rotateX(66deg) rotateZ(10deg)"></div>
<div class="planet" style="background:radial-gradient(circle at 30% 26%, var(--sakura), var(--nadeshiko) 48%, var(--ink-0) 94%);box-shadow:0 0 60px rgba(234,143,174,.28), inset -22px -26px 60px rgba(9,11,22,.8);animation:skFloat 8s ease-in-out infinite"></div>
<!-- лепестки -->
<div style="position:absolute;top:-4%;left:12%;width:10px;height:10px;border-radius:50% 0 50% 50%;background:var(--sakura);opacity:.8;animation:skFloatSlow 5s ease-in-out infinite"></div>
<div style="position:absolute;bottom:4%;right:-4%;width:8px;height:8px;border-radius:50% 0 50% 50%;background:var(--nadeshiko);opacity:.6;animation:skFloat 7s ease-in-out infinite"></div>
<!-- спутник: лепесток -->
<div class="sat-orbit" style="inset:-16%;transform:rotateX(66deg) rotateZ(10deg)">
<div class="sat-spin" style="animation:skSpin 16s linear infinite">
<div class="sat" style="width:11px;height:11px;border-radius:50% 0 50% 50%;background:var(--sakura);box-shadow:0 0 12px rgba(247,206,220,.6);transform:translate(-50%,-50%) rotateX(-66deg)"></div>
</div>
</div>
</div>
<div class="label">
<div class="jp"></div>
<div class="en">YUME · СОН</div>
<div class="meta">03 · 夢降下中 · СПУТНИК: ЛЕПЕСТОК --sakura</div>
</div>
</div>
<!-- ═══════ 04 · FUMI 文 — транскрибация (byakugun/hanada + лёд и капля) ═══════ -->
<div class="cell">
<div class="stage">
<div class="ring" style="inset:-15%;transform:rotateX(70deg)"></div>
<div class="ring" style="inset:-28%;border-style:dashed;transform:rotateX(70deg) rotateZ(16deg);animation:skSpinBack 52s linear infinite"></div>
<div class="planet" style="background:radial-gradient(circle at 32% 28%, var(--byakugun), var(--hanada) 50%, var(--ink-0) 93%);box-shadow:0 0 60px rgba(31,127,196,.30), inset -22px -26px 60px rgba(9,11,22,.85);animation:skFloatSlow 8.5s ease-in-out infinite"></div>
<div class="glow" style="box-shadow:0 0 40px rgba(143,214,220,.28);animation:skPulse 4.4s ease-in-out infinite"></div>
<!-- спутник 1: льдинка -->
<div class="sat-orbit" style="inset:-15%;transform:rotateX(70deg)">
<div class="sat-spin" style="animation:skSpin 14s linear infinite">
<div class="sat" style="width:10px;height:10px;border-radius:50%;background:var(--byakugun);box-shadow:0 0 14px rgba(143,214,220,.75);transform:translate(-50%,-50%) rotateX(-70deg)"></div>
</div>
</div>
<!-- спутник 2: капля на внешней орбите -->
<div class="sat-orbit" style="inset:-28%;transform:rotateX(70deg) rotateZ(16deg)">
<div class="sat-spin" style="animation:skSpinBack 26s linear infinite">
<div class="sat" style="width:7px;height:7px;border-radius:50%;background:var(--hanada);box-shadow:0 0 10px rgba(31,127,196,.7);transform:translate(-50%,-50%) rotateX(-70deg)"></div>
</div>
</div>
</div>
<div class="label">
<div class="jp"></div>
<div class="en">FUMI · ПИСЬМО</div>
<div class="meta">04 · 文字変換 · СПУТНИКИ: ЛЁД --byakugun + КАПЛЯ --hanada</div>
</div>
</div>
<!-- ═══════ 05 · HADO 波動 — напоминания (hanada/ruri + сигнал и эхо) ═══════ -->
<div class="cell">
<div class="stage">
<div class="ring" style="inset:-15%;transform:rotateX(69deg)"></div>
<div class="ring" style="inset:-27%;border-style:dashed;transform:rotateX(69deg) rotateZ(-8deg);animation:skSpinBack 48s linear infinite"></div>
<!-- эхо: волна напоминания расходится и возвращается -->
<div style="position:absolute;inset:-6%;border:1px solid rgba(143,214,220,.45);border-radius:50%;animation:skEcho 4.2s ease-out infinite"></div>
<div style="position:absolute;inset:-6%;border:1px solid rgba(53,92,214,.5);border-radius:50%;animation:skEcho 4.2s ease-out 2.1s infinite"></div>
<div class="planet" style="background:radial-gradient(circle at 32% 28%, var(--ruri), #24337F 55%, var(--ink-0) 92%);box-shadow:0 0 70px rgba(53,92,214,.35), inset -22px -26px 60px rgba(9,11,22,.85);animation:skFloat 7.5s ease-in-out infinite"></div>
<div class="glow" style="box-shadow:0 0 46px rgba(53,92,214,.4);animation:skPulse 2.6s ease-in-out infinite"></div>
<!-- спутник 1: сигнал -->
<div class="sat-orbit" style="inset:-15%;transform:rotateX(69deg)">
<div class="sat-spin" style="animation:skSpin 13s linear infinite">
<div class="sat" style="width:10px;height:10px;border-radius:50%;background:var(--byakugun);box-shadow:0 0 14px rgba(143,214,220,.75);transform:translate(-50%,-50%) rotateX(-69deg)"></div>
</div>
</div>
<!-- спутник 2: эхо возвращается по внешней орбите -->
<div class="sat-orbit" style="inset:-27%;transform:rotateX(69deg) rotateZ(-8deg)">
<div class="sat-spin" style="animation:skSpinBack 30s linear infinite">
<div class="sat" style="width:7px;height:7px;border-radius:50%;background:var(--ruri);box-shadow:0 0 10px rgba(53,92,214,.8);transform:translate(-50%,-50%) rotateX(-69deg)"></div>
</div>
</div>
</div>
<div class="label">
<div class="jp">波動</div>
<div class="en">HADO · НАПОМИНАНИЯ</div>
<div class="meta">05 · 波動共鳴 · СПУТНИКИ: СИГНАЛ --byakugun + ЭХО --ruri</div>
</div>
</div>
</div>
<div style="max-width:1240px;margin:48px auto 0;font-size:11px;letter-spacing:.15em;color:var(--nibi);line-height:2">
МИНИ-ВЕРСИЯ (ДЛЯ НАВИГАЦИИ): КРУГ 11PX С ТЕМ ЖЕ RADIAL-GRADIENT + BOX-SHADOW 0 0 8PX АКЦЕНТА .45<br>
НОВАЯ ПЛАНЕТА = СВОЙ АКЦЕНТ ИЗ ПАЛИТРЫ → ГРАДИЕНТ «АКЦЕНТ → ТЁМНЫЙ АКЦЕНТ → --ink-0 НА ~92%» + КОЛЬЦО rotateX(6672°) + 12 СПУТНИКА-МЕТАФОРЫ
</div>
</body>
</html>

View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sekai — service page template</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ═══ Sekai — шаблон страницы сервиса ═══
Заглушки для дизайнеров помечены классом .sk-ph (пунктир).
Заменить: кандзи + имя сервиса в шапке, номер сервиса справа,
кандзи + имя в футере. Контент кладётся в <main>. */
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:#070912}
::selection{background:rgba(143,214,220,.25)}
.sk-page{min-height:100vh;background:#070912;color:#F2ECE0;font-family:'Space Grotesk',sans-serif;
position:relative;overflow:hidden;display:flex;flex-direction:column}
.sk-layer{position:absolute;pointer-events:none;z-index:0}
/* — туманность — */
.sk-mist{top:26%;left:-22%;width:150%;height:52%;filter:blur(34px);opacity:.72;
animation:skNebula 46s ease-in-out infinite;
background:
radial-gradient(ellipse 42% 58% at 20% 60%, rgba(234,143,174,.20), transparent 70%),
radial-gradient(ellipse 58% 46% at 48% 44%, rgba(232,78,138,.14), transparent 72%),
radial-gradient(ellipse 38% 54% at 70% 57%, rgba(159,143,203,.17), transparent 70%),
radial-gradient(ellipse 28% 40% at 88% 51%, rgba(143,214,220,.11), transparent 70%),
radial-gradient(ellipse 52% 34% at 36% 62%, rgba(53,92,214,.11), transparent 72%)}
.sk-core{top:44%;left:-15%;width:135%;height:14%;filter:blur(14px);opacity:.7;
animation:skNebulaCore 58s ease-in-out infinite;
background:
radial-gradient(ellipse 20% 70% at 8% 71%, rgba(247,206,220,.22), transparent 70%),
radial-gradient(ellipse 14% 90% at 22% 52%, rgba(242,236,224,.18), transparent 70%),
radial-gradient(ellipse 18% 80% at 36% 48%, rgba(234,143,174,.22), transparent 72%),
radial-gradient(ellipse 12% 100% at 48% 38%, rgba(247,206,220,.24), transparent 70%),
radial-gradient(ellipse 16% 85% at 60% 43%, rgba(242,236,224,.17), transparent 72%),
radial-gradient(ellipse 13% 75% at 72% 43%, rgba(234,143,174,.20), transparent 70%),
radial-gradient(ellipse 18% 90% at 86% 55%, rgba(247,206,220,.18), transparent 72%),
radial-gradient(ellipse 10% 60% at 94% 61%, rgba(143,214,220,.12), transparent 70%)}
.sk-dust{top:44%;left:-15%;width:135%;height:14%;filter:blur(10px);opacity:.8;
animation:skNebula 46s ease-in-out infinite;
background:
radial-gradient(ellipse 20% 38% at 16% 72%, rgba(7,9,18,.6), transparent 75%),
radial-gradient(ellipse 28% 34% at 44% 55%, rgba(9,11,22,.55), transparent 75%),
radial-gradient(ellipse 18% 38% at 70% 50%, rgba(7,9,18,.55), transparent 75%),
radial-gradient(ellipse 14% 30% at 90% 68%, rgba(9,11,22,.5), transparent 75%)}
.sk-stars{inset:0;overflow:hidden}
.sk-band{top:44%;left:-15%;width:135%;height:14%;transform:rotate(-14deg)}
/* — сестринские планеты (другие сервисы) — */
.sk-planets{inset:0}
.sk-planet{position:absolute}
.sk-planet .orb-x,.sk-planet .orb-y{display:flex;flex-direction:column;align-items:center;gap:9px}
.sk-planet .dot{pointer-events:auto;border-radius:50%}
.sk-planet .lbl{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:#6B7099;
white-space:nowrap;opacity:0;transition:opacity .4s}
.sk-planet .dot:hover + .lbl{opacity:1}
.sk-planet--a{top:17%;left:7%}
.sk-planet--a .orb-x{animation:skOrbXa 19s ease-in-out infinite}
.sk-planet--a .orb-y{animation:skOrbYa 19s ease-in-out -4.75s infinite}
.sk-planet--a .dot{width:15px;height:15px;background:radial-gradient(circle at 32% 28%,#F7CEDC,#EA8FAE 50%,#090B16 92%);box-shadow:0 0 10px rgba(234,143,174,.4)}
.sk-planet--b{top:26%;right:8%}
.sk-planet--b .orb-x{animation:skOrbXb 27s ease-in-out infinite}
.sk-planet--b .orb-y{animation:skOrbYb 27s ease-in-out -20.25s infinite}
.sk-planet--b .dot{width:12px;height:12px;background:radial-gradient(circle at 32% 28%,#E84E8A,#C13E6A 52%,#090B16 92%);box-shadow:0 0 9px rgba(232,78,138,.4)}
.sk-planet--c{top:60%;left:4.5%}
.sk-planet--c .orb-x{animation:skOrbXc 14s ease-in-out infinite}
.sk-planet--c .orb-y{animation:skOrbYc 14s ease-in-out -3.5s infinite;gap:8px}
.sk-planet--c .dot{width:9px;height:9px;background:radial-gradient(circle at 34% 30%,#9F8FCB,#4A4177 55%,#090B16 92%);box-shadow:0 0 8px rgba(159,143,203,.4)}
/* — каркас страницы — */
.sk-header{position:relative;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;padding:30px 44px 0 44px}
.sk-logo{display:flex;align-items:baseline;gap:14px}
.sk-main{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:72px 32px 100px 32px;flex:1}
.sk-footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:22px 32px 36px 32px;
border-top:1px solid rgba(174,180,214,.16);display:flex;justify-content:center;gap:8px;
font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:#6B7099}
/* — заглушки: заменить содержимым сервиса —
Кандзи в шапке: Shippori Mincho 54px #F2ECE0, имя: Space Mono 13px .5em #6B7099 */
.sk-ph{border-bottom:1px dashed rgba(174,180,214,.3);padding-bottom:2px}
.sk-ph--kanji{display:flex;align-items:center;justify-content:center;width:54px;height:54px;
border:1px dashed rgba(174,180,214,.3);border-radius:8px;
font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.2em;color:#6B7099}
.sk-ph--name{font-family:'Space Mono',monospace;font-size:13px;letter-spacing:.5em;color:#6B7099}
.sk-ph--no{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:#6B7099;margin-top:14px}
@keyframes skTwinkle{0%,100%{opacity:.15}50%{opacity:.75}}
@keyframes skNebula{0%,100%{transform:rotate(-14deg) translateX(-2.5%)}50%{transform:rotate(-14deg) translateX(2.5%)}}
@keyframes skNebulaCore{0%,100%{transform:rotate(-14deg) translateX(3%)}50%{transform:rotate(-14deg) translateX(-3%)}}
@keyframes skOrbXa{0%,100%{transform:translateX(36px)}50%{transform:translateX(-36px)}}
@keyframes skOrbYa{0%,100%{transform:translateY(13px)}50%{transform:translateY(-13px)}}
@keyframes skOrbXb{0%,100%{transform:translateX(24px)}50%{transform:translateX(-24px)}}
@keyframes skOrbYb{0%,100%{transform:translateY(22px)}50%{transform:translateY(-22px)}}
@keyframes skOrbXc{0%,100%{transform:translateX(14px)}50%{transform:translateX(-14px)}}
@keyframes skOrbYc{0%,100%{transform:translateY(28px)}50%{transform:translateY(-28px)}}
</style>
</head>
<body>
<div class="sk-page">
<!-- атмосфера -->
<div class="sk-layer sk-stars" data-sk-stars></div>
<div class="sk-layer sk-mist"></div>
<div class="sk-layer sk-core"></div>
<div class="sk-layer sk-dust"></div>
<div class="sk-layer sk-band" data-sk-band></div>
<!-- сестринские планеты: подписи видны при hover -->
<div class="sk-layer sk-planets">
<div class="sk-planet sk-planet--a"><div class="orb-x"><div class="orb-y"><div class="dot"></div><span class="lbl">YUME</span></div></div></div>
<div class="sk-planet sk-planet--b"><div class="orb-x"><div class="orb-y"><div class="dot"></div><span class="lbl">MYAKU</span></div></div></div>
<div class="sk-planet sk-planet--c"><div class="orb-x"><div class="orb-y"><div class="dot"></div><span class="lbl">KIOKU</span></div></div></div>
</div>
<header class="sk-header">
<div class="sk-logo">
<!-- ЗАГЛУШКА: кандзи сервиса (Shippori Mincho 54px #F2ECE0) -->
<span class="sk-ph--kanji">漢字</span>
<!-- ЗАГЛУШКА: латинское имя сервиса -->
<span class="sk-ph sk-ph--name">SERVICE NAME</span>
</div>
<!-- ЗАГЛУШКА: номер сервиса, напр. 04 -->
<div class="sk-ph sk-ph--no">NO</div>
</header>
<!-- контент сервиса сюда -->
<main class="sk-main"></main>
<footer class="sk-footer">
<!-- ЗАГЛУШКА: кандзи + имя сервиса -->
<span class="sk-ph">漢字 SERVICE</span>
<span>· 世界 SEKAI</span>
</footer>
</div>
<script>
(function () {
function star(color, size, bright, x, y, dur, delay) {
var s = document.createElement('div');
s.style.cssText = 'position:absolute;border-radius:50%;' +
'left:' + x + '%;top:' + y + '%;width:' + size + 'px;height:' + size + 'px;' +
'background:' + color + ';' +
(bright ? 'box-shadow:0 0 6px ' + color + ';' : '') +
'animation:skTwinkle ' + dur + 's ease-in-out ' + delay + 's infinite';
return s;
}
// фоновые звёзды
var sky = document.querySelector('[data-sk-stars]');
for (var i = 0; i < 90; i++) {
sky.appendChild(star(
Math.random() < 0.2 ? '#8FD6DC' : '#F2ECE0',
Math.random() < 0.85 ? 1.5 : 2.5, false,
(Math.random() * 100).toFixed(2), (Math.random() * 100).toFixed(2),
(3 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)
));
}
// звёзды млечной полосы: гаусс вокруг дуги, левый край уходит ниже
var band = document.querySelector('[data-sk-band]');
for (var j = 0; j < 170; j++) {
var bright = Math.random() < 0.08;
var r = Math.random();
var color = r < 0.68 ? '#F2ECE0' : (r < 0.88 ? '#F7CEDC' : '#8FD6DC');
var g = (Math.random() + Math.random() + Math.random()) / 3;
var x = Math.random() * 100;
var arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * 0.35;
band.appendChild(star(
color,
bright ? 2.2 : (0.8 + Math.random() * 1.1).toFixed(1), bright,
x.toFixed(2), (6 + g * 55 + arc).toFixed(2),
(2.5 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)
));
}
})();
</script>
<template id="__bundler_thumbnail">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#070912"/><ellipse cx="50" cy="55" rx="34" ry="9" fill="#EA8FAE" opacity=".3" transform="rotate(-14 50 55)"/><rect x="30" y="32" width="16" height="16" rx="3" fill="none" stroke="#6B7099" stroke-dasharray="3 3"/><rect x="52" y="38" width="20" height="4" rx="2" fill="none" stroke="#6B7099" stroke-dasharray="3 3"/><circle cx="72" cy="28" r="2" fill="#8FD6DC"/></svg>
</template>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,8 @@ core/ Laravel app (created in Task 1)
app/Http/Controllers/Me/{ProfileController,EventsController,ChannelsController}.php
app/Http/Controllers/ActionController.php
app/Http/Controllers/TelegramWebhookController.php
app/Http/Controllers/InboxController.php
app/Http/Controllers/InboxController.php (design: docs/design/handoff)
public/inbox.js
app/Console/Commands/{SourceCreate,TickCommand,PurgeCommand}.php
config/hado.php
database/migrations/0001_01_01_000000_create_hado_tables.php
@@ -79,6 +80,7 @@ core/ Laravel app (created in Task 1)
routes/{api,web,console}.php
tests/Unit/Scheduling/*, tests/Unit/Ingest/*, tests/Feature/*
docker-compose.yml, core/Dockerfile, core/Dockerfile.dev, deploy/Caddyfile.snippet, README.md
docs/design/handoff/ дизайн-хендофф (прототип инбокса, токены, README)
```
---
@@ -4759,17 +4761,223 @@ Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
---
### Task 11: Web inbox page
### Task 11: Manual events and archive API
Design handoff (`docs/design/handoff/README.md`) added two features the spec did not have: manual event creation («+ Добавить») and an archive of closed events («書庫 Архив · N закрыто»). Both stay. Backend support goes here; the page uses it in Task 12.
**Files:**
- Create: `core/app/Http/Controllers/InboxController.php`, `core/resources/views/inbox.blade.php`
- Modify: `core/app/Http/Controllers/Me/EventsController.php` (add `store`, `archive`)
- Modify: `core/routes/web.php` (add `POST /me/events`, `GET /me/archive`)
- Test: `core/tests/Feature/ManualEventsTest.php`
**Interfaces:**
- Consumes: `EventUpserter::upsert(Source, items)`, `ApiPresenter::present`.
- Produces:
- `POST /me/events` `{title, subtitle?, due_date (Y-m-d), due_time? (H:i)}` → 201 with the presented event. Source is the reserved `manual` source (`Source::firstOrCreate(['name' => 'manual'])` with an unusable random token hash). `source_ref = "manual:<ulid>"`, `topic = ""`, `after_due = keep`, `fire_at = due_date 30 days` (so a manual event is «Грядёт» within 30 days and «Запланировано» before that, exactly as the prototype groups it), payload `{title, subtitle?, done_label: "Сделано"}`.
- `GET /me/archive` → `{events: [...]}` — the user's `done` and `expired` events, newest closed first (`updated_at desc`), max 100, each presented plus `closed_as`: `done_label` (default «Сделано») for `done`, «Истекло» for `expired`. `withdrawn` is not shown.
- `present()` gains `source` for `/me` responses (already added in `index`); manual events show `source = "manual"`; the page renders it as «вручную».
- [ ] **Step 1: Failing test**
`core/tests/Feature/ManualEventsTest.php`:
```php
<?php
namespace Tests\Feature;
use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ManualEventsTest extends TestCase
{
use RefreshDatabase;
private array $as = ['X-Remote-User' => 'nikita'];
protected function setUp(): void
{
parent::setUp();
config(['hado.default_tz' => 'UTC']);
CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
parent::tearDown();
}
public function test_creates_manual_event_grouped_by_date(): void
{
$this->postJson('/me/events', ['title' => 'Продлить страховку', 'subtitle' => 'раз в год', 'due_date' => '2026-09-20'], $this->as)
->assertCreated()
->assertJsonPath('state', 'preparing')
->assertJsonPath('source', 'manual')
->assertJsonPath('payload.title', 'Продлить страховку')
->assertJsonPath('payload.done_label', 'Сделано')
->assertJsonPath('fire_at', '2026-08-21');
$this->postJson('/me/events', ['title' => 'Далеко', 'due_date' => '2026-12-15'], $this->as)->assertJsonPath('state', 'scheduled');
$this->postJson('/me/events', ['title' => 'Сегодня', 'due_date' => '2026-09-04', 'due_time' => '19:40'], $this->as)
->assertJsonPath('state', 'today')->assertJsonPath('due_at', '2026-09-04T19:40');
$this->postJson('/me/events', ['title' => 'Прошло', 'due_date' => '2026-09-01'], $this->as)->assertJsonPath('state', 'overdue');
$this->assertSame(4, Event::count());
$this->assertSame('manual', Source::sole()->name);
$this->assertSame(AfterDue::Keep, Event::first()->after_due);
$this->assertSame(DueMode::Local, Event::first()->due_mode);
}
public function test_validation(): void
{
$this->postJson('/me/events', ['due_date' => '2026-09-20'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['title']);
$this->postJson('/me/events', ['title' => 'x'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['due_date']);
$this->postJson('/me/events', ['title' => 'x', 'due_date' => '2026-09-20', 'due_time' => '25:00'], $this->as)->assertStatus(422)->assertJsonValidationErrors(['due_time']);
}
public function test_manual_source_cannot_be_used_through_client_api(): void
{
$this->postJson('/me/events', ['title' => 'x', 'due_date' => '2026-09-20'], $this->as)->assertCreated();
$this->putJson('/api/events', ['events' => []], ['Authorization' => 'Bearer manual'])->assertStatus(401);
}
public function test_archive_lists_done_and_expired_newest_first(): void
{
$this->getJson('/me', $this->as);
$user = User::sole();
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
$make = fn (string $topic, EventState $state, string $updatedAt, ?string $doneLabel = null) => tap(Event::create([
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
'due_mode' => DueMode::Local, 'due_date' => '2026-08-28', 'due_time' => '24:00:00', 'fire_on' => '2026-08-28',
'after_due' => AfterDue::Expire, 'payload' => array_filter(['title' => "E $topic", 'done_label' => $doneLabel]),
'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
]), fn (Event $e) => Event::whereKey($e->id)->update(['updated_at' => $updatedAt]));
$make('a', EventState::Done, '2026-09-01 10:00:00', 'Поздравил');
$make('b', EventState::Expired, '2026-09-02 10:00:00');
$make('c', EventState::Withdrawn, '2026-09-03 10:00:00');
$make('d', EventState::Today, '2026-09-03 10:00:00');
$r = $this->getJson('/me/archive', $this->as)->assertOk()->assertJsonCount(2, 'events');
$this->assertSame(['b', 'a'], array_column($r->json('events'), 'topic'));
$this->assertSame(['Истекло', 'Поздравил'], array_column($r->json('events'), 'closed_as'));
}
}
```
- [ ] **Step 2: Run to verify failure**
```bash
cd core && php artisan test --filter ManualEventsTest
```
Expected: FAIL (405/404).
- [ ] **Step 3: Implement**
Add to `core/app/Http/Controllers/Me/EventsController.php`:
```php
use App\Ingest\EventUpserter;
use App\Models\Source;
use Illuminate\Support\Str;
// constructor becomes:
public function __construct(private readonly EventActions $actions, private readonly EventUpserter $upserter) {}
public function store(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->attributes->get('user');
$data = $request->validate([
'title' => ['required', 'string', 'max:200'],
'subtitle' => ['sometimes', 'nullable', 'string', 'max:200'],
'due_date' => ['required', 'date_format:Y-m-d'],
'due_time' => ['sometimes', 'nullable', 'date_format:H:i'],
]);
$source = Source::firstOrCreate(['name' => 'manual'], ['token_hash' => hash('sha256', Str::random(64))]);
$dueAt = $data['due_date'].(empty($data['due_time']) ? '' : 'T'.$data['due_time']);
[$result] = $this->upserter->upsert($source, [[
'user' => $user->login,
'source_ref' => 'manual:'.Str::ulid(),
'topic' => '',
'due_at' => $dueAt,
'fire_at' => \Carbon\CarbonImmutable::createFromFormat('Y-m-d', $data['due_date'])->subDays(30)->format('Y-m-d'),
'after_due' => 'keep',
'payload' => array_filter([
'title' => $data['title'],
'subtitle' => $data['subtitle'] ?? null,
'done_label' => 'Сделано',
], fn ($v) => $v !== null && $v !== ''),
]]);
$event = Event::with('source')->findOrFail($result['id']);
return response()->json(ApiPresenter::present($event) + ['source' => $event->source->name], 201);
}
public function archive(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->attributes->get('user');
$events = $user->events()->with('source')
->whereIn('state', [EventState::Done->value, EventState::Expired->value])
->orderByDesc('updated_at')->limit(100)->get();
return response()->json(['events' => $events->map(fn (Event $e) => ApiPresenter::present($e) + [
'source' => $e->source->name,
'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'),
])->values()]);
}
```
Routes, inside the `remote.user` group in `core/routes/web.php`:
```php
Route::post('/me/events', [MeEvents::class, 'store']);
Route::get('/me/archive', [MeEvents::class, 'archive']);
```
- [ ] **Step 4: Run tests**
```bash
cd core && php artisan test --filter "ManualEventsTest|MeApiTest"
```
Expected: all PASS.
- [ ] **Step 5: Spec and functional doc**
In `docs/superpowers/specs/2026-09-03-hado-core-design.md` §10 add after the route list: «`POST /me/events` — ручное событие: источник `manual` (зарезервирован, токена нет), `fire_at = срок 30 дней`, `after_due = keep`. `GET /me/archive` — закрытые (`done`, `expired`) за период retention, новые сверху, с подписью закрытия.» Update the inbox paragraph to mention the archive block and the add form. These were added by the design handoff on 2026-09-04.
- [ ] **Step 6: Commit**
```bash
cd /d/ai-projects/hado && git add -A && git commit -m "feat: manual events and archive API
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
```
---
### Task 12: Web inbox page (по дизайну handoff)
The page follows `docs/design/handoff/hado-inbox.dc.html` (working prototype) and `docs/design/handoff/README.md` (tokens and rules). Reproduce it in Blade + vanilla JS; the values below are copied from the prototype, not invented. Dark only. Sekai design system: 60% dusk, 30% text neutrals, ~10% cold accent (`--ruri`/`--byakugun`) on action buttons, counter and time. `--ruri` is never used as text. Errors are `--sakura`.
**Files:**
- Create: `core/app/Http/Controllers/InboxController.php`, `core/resources/views/inbox.blade.php`, `core/public/inbox.js`
- Modify: `core/routes/web.php` (add `GET /` inside the `remote.user` group)
- Delete: `core/resources/views/welcome.blade.php`
- Test: `core/tests/Feature/InboxPageTest.php`
**Interfaces:**
- Consumes: `/me/*` JSON endpoints (the page is plain HTML + vanilla JS calling them with `X-CSRF-TOKEN`).
- Produces: `GET /` renders groups `today`, `overdue`, `preparing`, `scheduled`; buttons «Помню» / done_label; settings block (tz, quiet hours, channels, Telegram link). Heartbeat every 30 s only when `document.visibilityState === 'visible'` and pointer/keyboard activity within 3 minutes.
- Consumes: `/me`, `/me/events` (GET/POST), `/me/events/{id}/ack|done`, `/me/archive`, `/me/channels*`, `/me/heartbeat`.
- Produces: `GET /` rendering the whole inbox server-side (groups, archive rows, settings); JS only toggles sections, submits forms and sends the heartbeat; every mutation reloads the page.
- [ ] **Step 1: Failing test**
@@ -4793,40 +5001,78 @@ class InboxPageTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config(['hado.default_tz' => 'UTC']);
CarbonImmutable::setTestNow('2026-09-04T12:00:00Z');
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
parent::tearDown();
}
public function test_requires_sso(): void
{
$this->get('/')->assertStatus(401);
}
public function test_renders_groups_and_buttons(): void
public function test_renders_groups_cards_archive_and_settings(): void
{
$this->get('/', ['X-Remote-User' => 'nikita']);
$user = User::sole();
$source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]);
$make = fn (string $topic, EventState $state, ?string $doneLabel = null) => Event::create([
'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-08-31',
'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'deep_link' => '/x', 'done_label' => $doneLabel]),
$manual = Source::create(['name' => 'manual', 'token_hash' => hash('sha256', 'y')]);
$make = fn (Source $src, string $topic, EventState $state, string $due, string $time = '24:00:00', ?string $doneLabel = null, ?string $link = '/x') => Event::create([
'source_id' => $src->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic,
'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $due,
'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'subtitle' => 'подзаголовок', 'deep_link' => $link, 'done_label' => $doneLabel]),
'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'),
]);
$make('a', EventState::Today, 'Поздравил');
$make('b', EventState::Overdue);
$make('c', EventState::Preparing);
$make('d', EventState::Scheduled);
$make('e', EventState::Done);
$make($source, 'a', EventState::Today, '2026-09-04', '19:40:00', 'Поздравил');
$make($source, 'b', EventState::Overdue, '2026-09-01');
$make($manual, 'c', EventState::Preparing, '2026-09-30', '24:00:00', null, null);
$make($source, 'd', EventState::Scheduled, '2026-12-15');
$make($source, 'e', EventState::Done, '2026-08-28', '24:00:00', 'Оплатил');
$make($source, 'f', EventState::Expired, '2026-08-20');
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
// шапка и счётчик
$this->assertStringContainsString('<title>(2) Hado</title>', $html);
$this->assertStringContainsString('波動', $html);
$this->assertStringContainsString('+ Добавить', $html);
// группы в порядке и с кандзи
$this->assertMatchesRegularExpression('/今日.*СЕГОДНЯ.*遅延.*ПРОСРОЧЕНО.*近日.*ГРЯДЁТ.*予定.*ЗАПЛАНИРОВАНО/su', $html);
// карточки
$this->assertStringContainsString('Событие a', $html);
$this->assertStringContainsString('Событие d', $html);
$this->assertStringNotContainsString('Событие e', $html);
$this->assertStringContainsString('19:40', $html);
$this->assertStringContainsString('СЕН', $html);
$this->assertStringContainsString('сегодня', $html);
$this->assertStringContainsString('3 дня назад', $html);
$this->assertStringContainsString('через 26 дней', $html);
$this->assertStringContainsString('вручную', $html);
$this->assertStringContainsString('Поздравил', $html);
$this->assertStringContainsString('Помню', $html);
$this->assertStringContainsString('Сделано', $html);
$this->assertStringContainsString('/me/heartbeat', $html);
$this->assertStringContainsString('visibilityState', $html);
// архив
$this->assertStringContainsString('2 закрыто', $html);
$this->assertStringContainsString('Оплатил', $html);
$this->assertStringContainsString('Истекло', $html);
// настройки, форма добавления, присутствие
$this->assertStringContainsString('nikita', $html);
$this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html);
$this->assertStringContainsString('/inbox.js', $html);
$this->assertStringContainsString('csrf-token', $html);
}
public function test_counter_hidden_when_zero(): void
{
$html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent();
$this->assertStringContainsString('<title>Hado</title>', $html);
$this->assertStringNotContainsString('class="counter"', $html);
}
}
```
@@ -4835,9 +5081,9 @@ class InboxPageTest extends TestCase
```bash
cd core && php artisan test --filter InboxPageTest
```
Expected: FAIL (`/` renders the Laravel welcome page or 404).
Expected: FAIL.
- [ ] **Step 3: Implement**
- [ ] **Step 3: Controller**
`core/app/Http/Controllers/InboxController.php`:
```php
@@ -4851,37 +5097,104 @@ use App\Http\Controllers\Me\ChannelsController;
use App\Http\Controllers\Me\ProfileController;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
class InboxController extends Controller
{
private const MONTHS = ['ЯНВ', 'ФЕВ', 'МАР', 'АПР', 'МАЙ', 'ИЮН', 'ИЮЛ', 'АВГ', 'СЕН', 'ОКТ', 'НОЯ', 'ДЕК'];
public function __construct(private readonly DueResolver $due) {}
public function __invoke(Request $request): View
{
/** @var User $user */
$user = $request->attributes->get('user');
$today = $user->now()->startOfDay();
$events = $user->events()->with('source')
$live = $user->events()->with('source')
->whereIn('state', EventState::nonTerminalValues())
->orderBy('due_date')->orderBy('due_instant')->get()
->map(fn (Event $e) => Presenter::present($e) + ['source' => $e->source->name]);
->get()
->map(fn (Event $e) => $this->card($e, $user, $today))
->sortBy('sort')->values();
$groups = [];
foreach (['today' => 'Сегодня', 'overdue' => 'Просрочено', 'preparing' => 'Грядёт', 'scheduled' => 'Запланировано'] as $state => $label) {
$groups[] = ['state' => $state, 'label' => $label, 'events' => $events->where('state', $state)->values()->all()];
foreach (['today' => ['今日', 'СЕГОДНЯ'], 'overdue' => ['遅延', 'ПРОСРОЧЕНО'], 'preparing' => ['近日', 'ГРЯДЁТ'], 'scheduled' => ['予定', 'ЗАПЛАНИРОВАНО']] as $state => [$jp, $ru]) {
$groups[] = ['state' => $state, 'jp' => $jp, 'ru' => $ru, 'events' => $live->where('state', $state)->values()->all()];
}
$archive = $user->events()->with('source')
->whereIn('state', [EventState::Done->value, EventState::Expired->value])
->orderByDesc('updated_at')->limit(100)->get()
->map(fn (Event $e) => [
'title' => $e->payload['title'],
'source' => self::sourceLabel($e->source->name),
'due' => Presenter::present($e)['due_at'],
'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'),
])->values()->all();
$profile = ProfileController::present($user);
return view('inbox', [
'profile' => ProfileController::present($user),
'profile' => $profile,
'title' => $profile['badge'] > 0 ? "({$profile['badge']}) Hado" : 'Hado',
'groups' => $groups,
'archive' => $archive,
'channels' => $user->channels()->orderBy('id')->get()->map(ChannelsController::present(...))->values()->all(),
'timezones' => \DateTimeZone::listIdentifiers(),
]);
}
/** @return array<string,mixed> */
private function card(Event $e, User $user, CarbonImmutable $today): array
{
$due = $this->due->resolve($e, $user);
$wholeDay = $due->moment->eq($due->day->addDay());
$diff = (int) round($today->diffInDays($due->day));
return [
'id' => $e->id,
'state' => $e->state->value,
'title' => $e->payload['title'],
'subtitle' => $e->payload['subtitle'] ?? null,
'link' => $e->payload['deep_link'] ?? null,
'done_label' => $e->payload['done_label'] ?? 'Сделано',
'can_remind' => $e->state !== EventState::Scheduled,
'source' => self::sourceLabel($e->source->name),
'due' => Presenter::present($e)['due_at'],
'due_month' => self::MONTHS[(int) $due->day->format('n') - 1],
'due_day' => $due->day->format('d'),
'due_time' => $wholeDay ? null : $due->moment->format('H:i'),
'rel' => self::relative($diff),
'sort' => $due->moment->getTimestamp(),
];
}
public static function sourceLabel(string $name): string
{
return $name === 'manual' ? 'вручную' : $name;
}
public static function relative(int $diff): string
{
$days = fn (int $n) => $n % 10 === 1 && $n % 100 !== 11 ? 'день' : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14) ? 'дня' : 'дней');
return match (true) {
$diff === 0 => 'сегодня',
$diff === 1 => 'завтра',
$diff > 1 => "через {$diff} ".$days($diff),
$diff === -1 => 'вчера',
default => abs($diff).' '.$days(abs($diff)).' назад',
};
}
}
```
`core/resources/views/inbox.blade.php`:
- [ ] **Step 4: Blade template**
`core/resources/views/inbox.blade.php` — structure and values mirror the prototype; sister planets, nebula and star field included as in the Sekai template:
```blade
<!doctype html>
<html lang="ru">
@@ -4889,108 +5202,254 @@ class InboxController extends Controller
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Hado · {{ $profile['badge'] > 0 ? '('.$profile['badge'].') ' : '' }}уведомления</title>
<title>{{ $title }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
body { font: 15px/1.4 system-ui, sans-serif; margin: 0; background: #f6f6f4; color: #1a1a1a; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px 64px; }
h1 { font-size: 20px; display: flex; align-items: center; gap: 10px; }
.badge { background: #d33; color: #fff; border-radius: 12px; padding: 0 8px; font-size: 13px; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: #666; margin: 28px 0 8px; }
.event { background: #fff; border-radius: 10px; padding: 12px 14px; margin-bottom: 8px; display: flex; gap: 12px; align-items: center; }
.event .text { flex: 1; min-width: 0; }
.event .title { font-weight: 600; }
.event .sub { color: #666; font-size: 13px; }
.event .meta { color: #999; font-size: 12px; }
button { border: 0; border-radius: 8px; padding: 8px 12px; cursor: pointer; font: inherit; }
.ack { background: #eee; }
.done { background: #1a1a1a; color: #fff; }
.empty { color: #999; padding: 8px 0; }
details { background: #fff; border-radius: 10px; padding: 12px 14px; margin-top: 36px; }
summary { cursor: pointer; font-weight: 600; }
form.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 10px 0; }
input, select { font: inherit; padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; }
ul.channels { list-style: none; padding: 0; }
ul.channels li { display: flex; gap: 8px; align-items: center; padding: 6px 0; border-top: 1px solid #eee; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; }
:root{--bg:#070912;--ink-0:#090B16;--ink-1:#11152E;--ink-2:#1B2148;--ink-3:#2B3566;--sakura:#F7CEDC;--nadeshiko:#EA8FAE;--tsutsuji:#E84E8A;--kobai:#C13E6A;--yamabuki:#F2B233;--hanada:#1F7FC4;--ruri:#355CD6;--byakugun:#8FD6DC;--fuji:#9F8FCB;--washi:#F2ECE0;--kasumi:#AEB4D6;--nibi:#6B7099;--hair:rgba(174,180,214,.16);--hair-2:rgba(174,180,214,.30)}
*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg)}
body{min-height:100vh;color:var(--washi);font-family:'Space Grotesk',sans-serif;position:relative;overflow-x:hidden;display:flex;flex-direction:column}
::selection{background:rgba(143,214,220,.25)}
a{color:var(--byakugun);text-decoration-color:rgba(143,214,220,.4)}a:hover{color:var(--washi)}
input,select,button{font:inherit}button[disabled]{opacity:.45;pointer-events:none}
.mono{font-family:'Space Mono',monospace}.serif{font-family:'Shippori Mincho',serif}
.layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
.nebula{position:absolute;pointer-events:none;z-index:0;top:26%;left:-22%;width:150%;height:52%;filter:blur(34px);opacity:.72;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 42% 58% at 20% 60%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 58% 46% at 48% 44%,rgba(232,78,138,.14),transparent 72%),radial-gradient(ellipse 38% 54% at 70% 57%,rgba(159,143,203,.17),transparent 70%),radial-gradient(ellipse 28% 40% at 88% 51%,rgba(143,214,220,.11),transparent 70%),radial-gradient(ellipse 52% 34% at 36% 62%,rgba(53,92,214,.11),transparent 72%)}
.core{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(14px);opacity:.7;animation:skNebulaCore 58s ease-in-out infinite;background:radial-gradient(ellipse 20% 70% at 8% 71%,rgba(247,206,220,.22),transparent 70%),radial-gradient(ellipse 14% 90% at 22% 52%,rgba(242,236,224,.18),transparent 70%),radial-gradient(ellipse 18% 80% at 36% 48%,rgba(234,143,174,.22),transparent 72%),radial-gradient(ellipse 12% 100% at 48% 38%,rgba(247,206,220,.24),transparent 70%),radial-gradient(ellipse 16% 85% at 60% 43%,rgba(242,236,224,.17),transparent 72%),radial-gradient(ellipse 13% 75% at 72% 43%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 18% 90% at 86% 55%,rgba(247,206,220,.18),transparent 72%),radial-gradient(ellipse 10% 60% at 94% 61%,rgba(143,214,220,.12),transparent 70%)}
.dust{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(10px);opacity:.8;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 20% 38% at 16% 72%,rgba(7,9,18,.6),transparent 75%),radial-gradient(ellipse 28% 34% at 44% 55%,rgba(9,11,22,.55),transparent 75%),radial-gradient(ellipse 18% 38% at 70% 50%,rgba(7,9,18,.55),transparent 75%),radial-gradient(ellipse 14% 30% at 90% 68%,rgba(9,11,22,.5),transparent 75%)}
.band{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;transform:rotate(-14deg)}
.sister{position:absolute}.sister .col{display:flex;flex-direction:column;align-items:center;gap:9px}
.skdot{pointer-events:auto;border-radius:50%}.sklbl{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s}.skdot:hover+.sklbl{opacity:1}
header{position:relative;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;padding:30px 44px 0}
.brand{display:flex;align-items:baseline;gap:14px}.brand .jp{font-family:'Shippori Mincho',serif;font-size:54px;line-height:1}.brand .lat{font-family:'Space Mono',monospace;font-size:13px;letter-spacing:.5em;color:var(--nibi)}
.counter{display:inline-flex;align-items:center;gap:8px;padding:5px 12px 5px 10px;border:1px solid rgba(53,92,214,.55);border-radius:999px;background:rgba(53,92,214,.14)}
.counter .dot{width:11px;height:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%,var(--ruri),#24337F 55%,var(--ink-0) 92%);animation:skPulseDot 2.6s ease-in-out infinite}
.counter .n{font-family:'Space Mono',monospace;font-size:12px;color:var(--byakugun)}
.tools{display:flex;align-items:center;gap:16px;margin-top:14px}.num{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
.btn{border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;transition:background .2s,border-color .2s,color .2s}
.primary{background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun)}.primary:hover{background:rgba(53,92,214,.4)}
.ghost{background:transparent;border:1px solid var(--hair-2);color:var(--kasumi)}.ghost:hover{border-color:var(--byakugun);color:var(--byakugun)}
.ghost.danger:hover{border-color:var(--nadeshiko);color:var(--sakura)}
main{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:72px 32px 100px;flex:1}
.panel{border:1px solid var(--hair);border-radius:14px;background:rgba(17,21,46,.7);overflow:hidden;margin-top:64px}
.panel>button.head{width:100%;display:flex;align-items:center;gap:14px;background:none;border:none;padding:18px 22px;cursor:pointer;text-align:left;color:var(--washi)}
.panel .head .jp{font-family:'Shippori Mincho',serif;font-size:16px}.panel .head .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)}.panel .head .chev{margin-left:auto;font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi)}
.panel .body{border-top:1px solid var(--hair);padding:18px 22px;display:flex;flex-direction:column;gap:10px}
.addform{margin-bottom:52px;border:1px dashed var(--hair-2);border-radius:14px;background:rgba(17,21,46,.7);padding:22px}
.label{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px}
.row{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end}
.field{display:flex;flex-direction:column;gap:6px}.field span{font-size:12px;color:var(--nibi)}
.field input,.field select{background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px}
.err{color:var(--sakura);font-size:12px;margin-top:12px}.ok{color:var(--byakugun);font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;margin-top:12px}
.group{margin-bottom:52px}.group .h{display:flex;align-items:baseline;gap:12px;margin-bottom:18px}.group .h .jp{font-family:'Shippori Mincho',serif;font-size:20px}.group .h .ru{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.35em;color:var(--kasumi)}.group .h .line{flex:1;border-bottom:1px solid var(--hair)}
.empty{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi);padding:12px 2px}
.cards{display:flex;flex-direction:column;gap:12px}
.card{background:rgba(27,33,72,.72);border:1px solid var(--hair);border-radius:12px;padding:16px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.plaque{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:64px;padding:8px 10px;border:1px solid var(--hair-2);border-radius:10px;background:var(--ink-1)}
.plaque .m{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.2em;color:var(--nibi)}.plaque .d{font-family:'Shippori Mincho',serif;font-size:24px;line-height:1}.plaque .t{font-family:'Space Mono',monospace;font-size:10px;color:var(--byakugun)}
.card .text{flex:1;min-width:220px;display:flex;flex-direction:column;gap:5px}
.card .title{font-size:16px;font-weight:500;color:var(--washi);align-self:flex-start}.card a.title{text-decoration:underline dotted var(--hair-2);text-underline-offset:4px}.card a.title:hover{color:var(--byakugun)}
.card .sub{font-size:13px;color:var(--kasumi)}.card .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi);margin-top:3px}.card .meta .rel{color:var(--kasumi)}
.card .actions{display:flex;gap:10px;align-items:center}
.arow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1);opacity:.75}
.arow .t{font-size:14px;color:var(--kasumi);flex:1;min-width:180px}.arow .m{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi)}.arow .tag{font-family:'Space Mono',monospace;font-size:10px;letter-spacing:.15em;color:var(--fuji);border:1px solid var(--hair);border-radius:999px;padding:3px 10px}
.chrow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1)}.chrow .n{font-size:14px;min-width:76px}.chrow .note{font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi);flex:1;overflow-wrap:anywhere}
.tgbox{border:1px dashed var(--hair-2);border-radius:10px;padding:14px 18px;display:flex;flex-direction:column;gap:8px}
footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:22px 32px 36px;border-top:1px solid var(--hair);display:flex;justify-content:center;gap:8px;font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
@keyframes skTwinkle{0%,100%{opacity:.15}50%{opacity:.75}}
@keyframes skNebula{0%,100%{transform:rotate(-14deg) translateX(-2.5%)}50%{transform:rotate(-14deg) translateX(2.5%)}}
@keyframes skNebulaCore{0%,100%{transform:rotate(-14deg) translateX(3%)}50%{transform:rotate(-14deg) translateX(-3%)}}
@keyframes skOrbXa{0%,100%{transform:translateX(36px)}50%{transform:translateX(-36px)}}@keyframes skOrbYa{0%,100%{transform:translateY(13px)}50%{transform:translateY(-13px)}}
@keyframes skOrbXb{0%,100%{transform:translateX(24px)}50%{transform:translateX(-24px)}}@keyframes skOrbYb{0%,100%{transform:translateY(22px)}50%{transform:translateY(-22px)}}
@keyframes skOrbXc{0%,100%{transform:translateX(14px)}50%{transform:translateX(-14px)}}@keyframes skOrbYc{0%,100%{transform:translateY(28px)}50%{transform:translateY(-28px)}}
@keyframes skOrbXd{0%,100%{transform:translateX(-20px)}50%{transform:translateX(20px)}}@keyframes skOrbYd{0%,100%{transform:translateY(16px)}50%{transform:translateY(-16px)}}
@keyframes skPulseDot{0%,100%{box-shadow:0 0 6px rgba(53,92,214,.5)}50%{box-shadow:0 0 14px rgba(53,92,214,.95)}}
</style>
</head>
<body>
<div class="layer" id="stars"></div>
<div class="nebula"></div><div class="core"></div><div class="dust"></div>
<div class="band" id="band"></div>
{{-- сёстры-планеты: все, кроме Hado --}}
<div class="layer">
<div class="sister" style="top:17%;left:7%"><div style="animation:skOrbXa 19s ease-in-out infinite"><div class="col" style="animation:skOrbYa 19s ease-in-out -4.75s infinite"><div class="skdot" style="width:15px;height:15px;background:radial-gradient(circle at 32% 28%,var(--sakura),var(--nadeshiko) 50%,var(--ink-0) 92%);box-shadow:0 0 10px rgba(234,143,174,.4)"></div><span class="sklbl">YUME</span></div></div></div>
<div class="sister" style="top:26%;right:8%"><div style="animation:skOrbXb 27s ease-in-out infinite"><div class="col" style="animation:skOrbYb 27s ease-in-out -20.25s infinite"><div class="skdot" style="width:12px;height:12px;background:radial-gradient(circle at 32% 28%,var(--tsutsuji),var(--kobai) 52%,var(--ink-0) 92%);box-shadow:0 0 9px rgba(232,78,138,.4)"></div><span class="sklbl">MYAKU</span></div></div></div>
<div class="sister" style="top:60%;left:4.5%"><div style="animation:skOrbXc 14s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYc 14s ease-in-out -3.5s infinite"><div class="skdot" style="width:9px;height:9px;background:radial-gradient(circle at 34% 30%,var(--fuji),#4A4177 55%,var(--ink-0) 92%);box-shadow:0 0 8px rgba(159,143,203,.4)"></div><span class="sklbl">KIOKU</span></div></div></div>
<div class="sister" style="top:64%;right:6%"><div style="animation:skOrbXd 22s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYd 22s ease-in-out -5.5s infinite"><div class="skdot" style="width:10px;height:10px;background:radial-gradient(circle at 32% 28%,var(--byakugun),var(--hanada) 50%,var(--ink-0) 93%);box-shadow:0 0 9px rgba(143,214,220,.4)"></div><span class="sklbl">FUMI</span></div></div></div>
</div>
<header>
<div class="brand">
<span class="jp">波動</span><span class="lat">HADO</span>
@if($profile['badge'] > 0)<span class="counter"><span class="dot"></span><span class="n">{{ $profile['badge'] }}</span></span>@endif
</div>
<div class="tools">
<button class="btn primary" id="toggle-add">+ Добавить</button>
<span class="num">05</span>
</div>
</header>
<main>
<h1>Hado @if($profile['badge'] > 0)<span class="badge">{{ $profile['badge'] }}</span>@endif</h1>
<section class="addform" id="addform" hidden>
<div class="label">НОВОЕ СОБЫТИЕ</div>
<form class="row" id="add">
<label class="field" style="flex:2;min-width:220px"><span>Заголовок</span><input name="title" placeholder="Продлить страховку" required maxlength="200"></label>
<label class="field" style="flex:1;min-width:160px"><span>Подзаголовок (опц.)</span><input name="subtitle" placeholder="раз в год" maxlength="200"></label>
<label class="field"><span>Срок</span><input name="due_date" type="date" required></label>
<label class="field"><span>Время (опц.)</span><input name="due_time" type="time"></label>
<button type="submit" class="btn primary">Создать</button>
<button type="button" class="btn ghost" id="cancel-add">Отмена</button>
</form>
<div class="err" id="add-err" hidden></div>
</section>
@foreach($groups as $group)
<h2>{{ $group['label'] }}</h2>
@forelse($group['events'] as $event)
<div class="event" data-id="{{ $event['id'] }}">
<div class="text">
<div class="title">
@if(!empty($event['payload']['deep_link']))<a href="{{ $event['payload']['deep_link'] }}">{{ $event['payload']['title'] }}</a>@else{{ $event['payload']['title'] }}@endif
</div>
@if(!empty($event['payload']['subtitle']))<div class="sub">{{ $event['payload']['subtitle'] }}</div>@endif
<div class="meta">{{ $event['source'] }} · срок {{ $event['due_at'] }}</div>
</div>
@if($group['state'] !== 'scheduled')
<button class="ack" data-action="ack">Помню</button>
@endif
<button class="done" data-action="done">{{ $event['payload']['done_label'] ?? 'Сделано' }}</button>
<section class="group">
<div class="h"><span class="jp">{{ $group['jp'] }}</span><span class="ru">{{ $group['ru'] }}</span><span class="line"></span></div>
@if(empty($group['events']))<div class="empty">ПУСТО</div>@endif
<div class="cards">
@foreach($group['events'] as $ev)
<article class="card" data-id="{{ $ev['id'] }}">
<div class="plaque"><span class="m">{{ $ev['due_month'] }}</span><span class="d">{{ $ev['due_day'] }}</span>@if($ev['due_time'])<span class="t">{{ $ev['due_time'] }}</span>@endif</div>
<div class="text">
@if($ev['link'])<a class="title" href="{{ $ev['link'] }}">{{ $ev['title'] }}</a>@else<span class="title">{{ $ev['title'] }}</span>@endif
@if($ev['subtitle'])<span class="sub">{{ $ev['subtitle'] }}</span>@endif
<span class="meta">{{ $ev['source'] }} · срок {{ $ev['due'] }} · <span class="rel">{{ $ev['rel'] }}</span></span>
</div>
<div class="actions">
@if($ev['can_remind'])<button class="btn ghost" data-action="ack">Помню</button>@endif
<button class="btn primary" data-action="done">{{ $ev['done_label'] }}</button>
</div>
</article>
@endforeach
</div>
@empty
<div class="empty">Пусто</div>
@endforelse
</section>
@endforeach
<details>
<summary>Настройки · {{ $profile['login'] }}</summary>
<form class="row" id="profile">
<label>Пояс
<select name="tz">
@foreach($timezones as $tz)<option value="{{ $tz }}" @selected($tz === $profile['tz'])>{{ $tz }}</option>@endforeach
</select>
</label>
<label>Тихие часы с <input name="quiet_start" type="time" value="{{ $profile['quiet_start'] }}"></label>
<label>до <input name="quiet_end" type="time" value="{{ $profile['quiet_end'] }}"></label>
<button type="submit" class="done">Сохранить</button>
</form>
<h2>Каналы</h2>
<ul class="channels">
@foreach($channels as $channel)
<li>
<strong>{{ $channel['type'] }}</strong>
<span class="meta">{{ $channel['type'] === 'webhook' ? $channel['config']['deliver_url'] : ($channel['type'] === 'telegram' ? 'chat '.$channel['config']['chat_id'] : 'этот инбокс') }}</span>
@if($channel['type'] !== 'web')<button class="ack" data-delete-channel="{{ $channel['id'] }}">Удалить</button>@endif
</li>
<section class="panel" data-panel="archive">
<button class="head" type="button"><span class="jp">書庫</span><span>Архив</span><span class="meta">{{ count($archive) }} закрыто</span><span class="chev">▼</span></button>
<div class="body" hidden>
@if(empty($archive))<div class="empty">ПУСТО</div>@endif
@foreach($archive as $ar)
<div class="arow"><span class="t">{{ $ar['title'] }}</span><span class="m">{{ $ar['source'] }} · {{ $ar['due'] }}</span><span class="tag">{{ $ar['closed_as'] }}</span></div>
@endforeach
</ul>
</div>
</section>
<form class="row" id="webhook">
<input name="deliver_url" placeholder="Webhook: deliver_url" size="34" required>
<input name="presence_url" placeholder="presence_url" size="34" required>
<button type="submit" class="done">Добавить webhook</button>
</form>
<section class="panel" data-panel="settings">
<button class="head" type="button"><span class="jp">設定</span><span>Настройки</span><span class="meta">{{ $profile['login'] }}</span><span class="chev">▼</span></button>
<div class="body" hidden style="padding:26px 22px 30px;gap:34px">
<div>
<div class="label">ПОЯС И ТИХИЕ ЧАСЫ</div>
<form class="row" id="profile">
<label class="field" style="min-width:240px"><span>Часовой пояс</span>
<select name="tz">@foreach($timezones as $tz)<option value="{{ $tz }}" @selected($tz === $profile['tz'])>{{ $tz }}</option>@endforeach</select>
</label>
<label class="field"><span>Тихие часы, с</span><input name="quiet_start" type="time" value="{{ $profile['quiet_start'] }}"></label>
<label class="field"><span>до</span><input name="quiet_end" type="time" value="{{ $profile['quiet_end'] }}"></label>
<button type="submit" class="btn primary">Сохранить</button>
</form>
<div class="err" id="profile-err" hidden></div>
</div>
<form class="row" id="telegram">
<button type="submit" class="done">Подключить Telegram</button>
<span id="tg-result"></span>
</form>
</details>
<div>
<div class="label">КАНАЛЫ</div>
<div style="display:flex;flex-direction:column;gap:10px">
@foreach($channels as $ch)
<div class="chrow">
<span class="n">{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }}</span>
<span class="note">{{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.$ch['config']['chat_id'] : $ch['config']['deliver_url']) }}</span>
@if($ch['type'] !== 'web')<button class="btn ghost danger" style="padding:6px 12px;font-size:12px" data-delete-channel="{{ $ch['id'] }}">Удалить</button>@endif
</div>
@endforeach
</div>
</div>
<div>
<div class="label">ДОБАВИТЬ WEBHOOK</div>
<form class="row" id="webhook">
<label class="field" style="flex:1;min-width:220px"><span>Адрес доставки</span><input name="deliver_url" type="url" placeholder="https://home.local/hado/notify" required></label>
<label class="field" style="flex:1;min-width:220px"><span>Адрес присутствия</span><input name="presence_url" type="url" placeholder="https://home.local/presence" required></label>
<button type="submit" class="btn ghost">Добавить webhook</button>
</form>
<div class="err" id="webhook-err" hidden></div>
</div>
<div>
<div class="label">TELEGRAM</div>
<button class="btn ghost" id="tg-connect">Подключить Telegram</button>
<div class="tgbox" id="tg-box" hidden>
<span style="font-size:14px">Отправь боту <span class="mono" style="color:var(--byakugun)" id="tg-code"></span> или <a id="tg-link" href="#" target="_blank" rel="noopener">открой</a></span>
<span class="mono" style="font-size:11px;letter-spacing:.15em;color:var(--nibi)">Код живёт 15 минут. Канал появится в списке при следующем открытии страницы.</span>
</div>
</div>
</div>
</section>
</main>
<script>
<footer><span>波動 HADO</span><span>· 世界 SEKAI</span></footer>
<script src="/inbox.js"></script>
</body>
</html>
```
- [ ] **Step 5: Page script**
`core/public/inbox.js`:
```js
(() => {
const csrf = document.querySelector('meta[name=csrf-token]').content;
const call = (method, url, body) => fetch(url, {
method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
body: body ? JSON.stringify(body) : undefined,
});
const showErr = async (el, r) => {
const d = await r.json().catch(() => ({}));
el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка';
el.hidden = false;
};
// Кнопки «Помню» / «Сделано»
document.querySelectorAll('.event button[data-action]').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('.event');
btn.disabled = true;
// Звёзды: 90 по небу, 170 в полосе (дуга 14°)
const star = (color, size, bright, x, y, dur, delay) => {
const s = document.createElement('div');
s.style.cssText = `position:absolute;border-radius:50%;left:${x}%;top:${y}%;width:${size}px;height:${size}px;background:${color};${bright ? `box-shadow:0 0 6px ${color};` : ''}animation:skTwinkle ${dur}s ease-in-out ${delay}s infinite`;
return s;
};
const sky = document.getElementById('stars'), band = document.getElementById('band');
for (let i = 0; i < 90; i++) sky.appendChild(star(Math.random() < .2 ? '#8FD6DC' : '#F2ECE0', Math.random() < .85 ? 1.5 : 2.5, false, (Math.random() * 100).toFixed(2), (Math.random() * 100).toFixed(2), (3 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
for (let j = 0; j < 170; j++) {
const bright = Math.random() < .08, r = Math.random();
const color = r < .68 ? '#F2ECE0' : (r < .88 ? '#F7CEDC' : '#8FD6DC');
const g = (Math.random() + Math.random() + Math.random()) / 3, x = Math.random() * 100;
const arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * .35;
band.appendChild(star(color, bright ? 2.2 : (0.8 + Math.random() * 1.1).toFixed(1), bright, x.toFixed(2), (6 + g * 55 + arc).toFixed(2), (2.5 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
}
// Панели архив/настройки
document.querySelectorAll('.panel .head').forEach(h => h.addEventListener('click', () => {
const body = h.nextElementSibling; body.hidden = !body.hidden;
h.querySelector('.chev').textContent = body.hidden ? '▼' : '▲';
}));
// Форма добавления
const addform = document.getElementById('addform');
const toggleAdd = () => { addform.hidden = !addform.hidden; document.getElementById('add-err').hidden = true; };
document.getElementById('toggle-add').addEventListener('click', toggleAdd);
document.getElementById('cancel-add').addEventListener('click', toggleAdd);
document.getElementById('add').addEventListener('submit', async e => {
e.preventDefault();
const f = new FormData(e.target);
const r = await call('POST', '/me/events', { title: f.get('title'), subtitle: f.get('subtitle') || null, due_date: f.get('due_date'), due_time: f.get('due_time') || null });
if (r.ok) location.reload(); else showErr(document.getElementById('add-err'), r);
});
// «Помню» / «Сделано»
document.querySelectorAll('.card button[data-action]').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('.card');
card.querySelectorAll('button').forEach(b => b.disabled = true);
const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`);
if (r.ok) location.reload(); else btn.disabled = false;
if (r.ok) location.reload(); else card.querySelectorAll('button').forEach(b => b.disabled = false);
}));
// Настройки
@@ -4998,27 +5457,28 @@ class InboxController extends Controller
e.preventDefault();
const f = new FormData(e.target);
const r = await call('PATCH', '/me', { tz: f.get('tz'), quiet_start: f.get('quiet_start'), quiet_end: f.get('quiet_end') });
if (r.ok) location.reload(); else alert((await r.json()).message || 'Ошибка');
if (r.ok) location.reload(); else showErr(document.getElementById('profile-err'), r);
});
document.getElementById('webhook').addEventListener('submit', async e => {
e.preventDefault();
const f = new FormData(e.target);
const r = await call('POST', '/me/channels', { type: 'webhook', config: { deliver_url: f.get('deliver_url'), presence_url: f.get('presence_url') } });
if (r.ok) location.reload(); else alert((await r.json()).message || 'Ошибка');
});
document.getElementById('telegram').addEventListener('submit', async e => {
e.preventDefault();
const r = await call('POST', '/me/channels/telegram/link');
const d = await r.json();
document.getElementById('tg-result').innerHTML = `Отправь боту <code>/start ${d.code}</code> или <a href="${d.bot_url}" target="_blank">открой</a>`;
if (r.ok) location.reload(); else showErr(document.getElementById('webhook-err'), r);
});
document.querySelectorAll('[data-delete-channel]').forEach(btn => btn.addEventListener('click', async () => {
const r = await call('DELETE', `/me/channels/${btn.dataset.deleteChannel}`);
if (r.ok) location.reload();
}));
document.getElementById('tg-connect').addEventListener('click', async e => {
const r = await call('POST', '/me/channels/telegram/link');
if (!r.ok) return;
const d = await r.json();
document.getElementById('tg-code').textContent = `/start ${d.code}`;
document.getElementById('tg-link').href = d.bot_url;
e.target.hidden = true; document.getElementById('tg-box').hidden = false;
});
// Присутствие: heartbeat только если вкладка видима и была активность за 3 минуты.
// Забытая фоновая вкладка не должна отвечать «пользователь тут» и глотать доставки.
// Присутствие: heartbeat только при видимой вкладке и активности за 3 минуты.
let lastActivity = Date.now();
['pointerdown', 'pointermove', 'keydown', 'scroll'].forEach(ev => addEventListener(ev, () => { lastActivity = Date.now(); }, { passive: true }));
const heartbeat = () => {
@@ -5030,35 +5490,28 @@ class InboxController extends Controller
setInterval(heartbeat, 30 * 1000);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { lastActivity = Date.now(); heartbeat(); } });
})();
</script>
</body>
</html>
```
Add to the `remote.user` group in `core/routes/web.php`:
```php
Route::get('/', \App\Http\Controllers\InboxController::class);
```
and delete `core/resources/views/welcome.blade.php`.
Delete `core/resources/views/welcome.blade.php`.
- [ ] **Step 4: Run tests**
- [ ] **Step 6: Run tests, Pint, whole suite**
```bash
cd core && php artisan test --filter InboxPageTest
cd core && php artisan test --filter InboxPageTest && php artisan test && vendor/bin/pint --test
```
Expected: 2 tests PASS.
Expected: all green. In `test_renders_groups_cards_archive_and_settings` the relative strings depend on `setTestNow` (2026-09-04): «сегодня» for 09-04, «3 дня назад» for 09-01, «через 26 дней» for 09-30.
- [ ] **Step 5: Run the whole suite and Pint**
- [ ] **Step 7: Visual check against the prototype**
Open `docs/design/handoff/hado-inbox.dc.html` in a browser (keep `support.js` next to it) and `http://localhost:8080/` with an `X-Remote-User` header (Task 13 compose). Compare: header, counter pill, group headings, card plaque, buttons, archive rows, settings. Fix spacing or token mismatches in `inbox.blade.php` only; do not change behavior.
- [ ] **Step 8: Commit**
```bash
cd core && php artisan test && vendor/bin/pint --test
```
Expected: all green; if Pint reports style issues run `vendor/bin/pint` and re-run tests.
- [ ] **Step 6: Commit**
```bash
cd /d/ai-projects/hado && git add -A && git commit -m "feat: web inbox with presence heartbeat
cd /d/ai-projects/hado && git add -A && git commit -m "feat: web inbox per Sekai design handoff
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
@@ -5066,7 +5519,7 @@ Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
---
### Task 12: Docker Compose, Dockerfiles, Caddy snippet, README
### Task 13: Docker Compose, Dockerfiles, Caddy snippet, README
**Files:**
- Create: `docker-compose.yml`, `core/Dockerfile`, `core/Dockerfile.dev`, `deploy/Caddyfile.snippet`, `README.md`, `.env.example` (root, for compose)
@@ -5254,10 +5707,10 @@ Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw"
| §6 states, transitions, eve rule, recompute on upsert | 3, 4, 5, 9 |
| §7 cascade | 3 |
| §8 windows, truncated window fallback, quiet rule, ack/done paths | 4, 7, 10 |
| §9 drivers, presence table, web heartbeat rule, tick algorithm, pending row, retries | 8, 9, 11 |
| §10 `/me` API, inbox, tz/quiet hours, channels, telegram link | 7, 10, 11 |
| §11 three auth mechanisms, Caddy bypass paths | 6, 7, 10, 12 |
| §12 compose, scheduler, worker, env | 12 |
| §9 drivers, presence table, web heartbeat rule, tick algorithm, pending row, retries | 8, 9, 12 |
| §10 `/me` API, inbox, tz/quiet hours, channels, telegram link, manual events, archive | 7, 10, 11, 12 |
| §11 three auth mechanisms, Caddy bypass paths | 6, 7, 10, 13 |
| §12 compose, scheduler, worker, env | 13 |
| §13 tests | every task |
Known simplification vs spec: presence probes run sequentially per user (each cached 60 s) instead of in parallel; only `webhook` does network I/O, so the difference is at most one 2-second timeout per user per minute. Note it in the spec §9 if it ever matters.

View File

@@ -414,9 +414,23 @@ POST /me/channels/telegram/link → { code, bot_url } // затем /start <
`HADO_DEFAULT_TZ`. Смена `tz` — руками через `PATCH /me` или из HA; позже iOS будет
обновлять сама.
Веб-инбокс — одна Blade-страница под SSO: список событий по группам `today`,
`overdue`, `preparing`, `scheduled`, у каждого кнопки «Помню» / «Сделано»,
`deep_link` ведёт в клиент. Блок настроек: пояс, тихие часы, каналы, привязка
Два дополнения по дизайн-хендоффу от 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. Доступ