Compare commits

..

20 Commits

Author SHA1 Message Date
aleksey 99855dc2f6 docs(ai): канон PRODUCT-AI (Фаза 3, волна 3) — ИИ молчит и делает
PRODUCT-AI.md: L0 умность в ячейках (Time Arc v1-v4 shipped); L1 подсказки
ghosts kind=hint (Front#75); L2 голос-кнопка mark press-to-talk -> ActionPlan
с подтверждением пишущих действий + палитра fallback (Front#76). STT/intent
через Back-прокси /v1/ai/*; anti-scope: чат, Composer, AI-home, авто-booking,
AiRouter в Front. Ссылки: FrontSpec §3.3 + новый §7.3, README.

Refs EventHub/EventHubSpec#22
2026-08-19 22:36:41 +03:00
aleksey 59726d394c docs(search): вид карты в поиске, default_location всегда
FrontSpec §5.3: переключатель Список/Карта (?view=map), маркеры
группируются по месту, пин календаря с числом событий, попап со ссылками,
тайлы OpenFreeMap без ключа, lazy-chunk. BackSpec §2.5: default_location
календарей возвращается всегда, distance_km — только при гео-запросе
(Back#77).

Refs EventHub/EventHubSpec#21
2026-08-19 22:01:21 +03:00
aleksey b79fd81260 docs(geo): канон переведён на внешний геокодер OpenCage
Стек/Ops/контракт: OpenCage API через прокси бэкенда, ключ не покидает
сервер; source=opencage; free-тариф 2500 запросов/сутки и 1 rps,
402/403/429 апстрима -> 503 geo_unavailable; ToS разрешает хранение
результатов. Self-host Photon исключён из стека.

Refs EventHub/EventHubSpec#21
2026-08-18 23:30:23 +03:00
aleksey 588a282b6d docs(geo): drop picsum from CSP; seed covers are same-origin. Refs EventHub/EventHubSpec#21 2026-08-18 14:31:21 +03:00
aleksey 65afff6629 docs(geo): CSP img-src includes picsum for stand seed covers. Refs EventHub/EventHubSpec#21 2026-08-18 13:06:55 +03:00
aleksey 4a96834d8e docs(geo): OSM/Photon canon; stage without Photon. Refs EventHub/EventHubSpec#21 2026-08-17 23:23:25 +03:00
aleksey 14da77fd7e docs(archive): hot/warm/cold month snapshots; HTML /view removed.
Fixes EventHub/EventHubSpec#20

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:59:34 +03:00
aleksey f68f39f5f9 docs(branding): calentiq.com Domain/DNS checklist live; Spec#8 ops wording. [skip ci] 2026-08-16 22:54:42 +03:00
aleksey 9b4499e884 docs(spec): sessions API, device fields, profile sessions UI. Refs EventHub/EventHubBack#74 2026-08-16 18:30:33 +03:00
aleksey 4dd741ba48 docs(spec): calendar_share co-editor/deputy and Time Arc alignment.
Back §2.1.3 API/ACL; Front §3.3 share busy/ghost. Refs EventHub/EventHubBack#73
2026-08-15 23:20:39 +03:00
aleksey 1182568c9d docs: Web Push and notification prefs (Back#75 / Front#71). 2026-08-15 20:37:52 +03:00
aleksey 515db26a0c docs(spec): mark Front#70 waitlist join/leave UI as done.
Refs EventHub/EventHubFront#70
2026-08-14 21:49:16 +03:00
aleksey 3189390794 docs(spec): waitlist API and commercial waitlist_enabled setting.
Refs EventHub/EventHubBack#72
Refs EventHub/EventHubFront#70
2026-08-14 21:44:20 +03:00
aleksey 07e89052fe docs(spec): document avatar/cover upload API (Back#71).
Refs EventHub/EventHubBack#71
Refs EventHub/EventHubSpec#8
2026-08-14 21:27:41 +03:00
aleksey 02e49a40f5 docs(spec): document booking email reminders (Back#70).
Refs EventHub/EventHubBack#70
Refs EventHub/EventHubSpec#8
2026-08-14 20:59:53 +03:00
aleksey dbabc4e8f3 docs(spec): document POST /v1/logout and admin logout. Refs EventHub/EventHubBack#69 2026-08-14 20:23:41 +03:00
aleksey 436e0a70c0 docs(spec): phase2 scope, Spec#18 pilot stub subscription, SMTP note. Refs EventHub/EventHubSpec#8 Fixes EventHub/EventHubSpec#18 2026-08-14 20:08:38 +03:00
aleksey 0fa213a1f8 docs(spec): calendar GET and /c/:id accept unique short_name. 2026-08-14 15:01:05 +03:00
aleksey 28e2795199 docs(spec): guest has no reports/votes; phrase draft rolls past hour. Refs EventHub/EventHubFront#65 2026-08-14 13:59:43 +03:00
aleksey 938a022ad8 chore(rules): ignore .tmp scratch; IFT e2e needs SMOKE_USER. [skip ci] 2026-08-14 12:52:17 +03:00
14 changed files with 1253 additions and 369 deletions
+120 -119
View File
@@ -1,119 +1,120 @@
---
description: Типичные ошибки агентов EventHub — shell, git, docker, scope, токены (корень + субагенты)
alwaysApply: true
---
# EventHub: типичные ошибки агентов (избегать)
Канон. Субагентам: включать в промпт «следуй `agent-pitfalls.mdc` + `shell-wsl-not-powershell.mdc`».
## 1. Shell / PowerShell
| Симптом | Причина | Как правильно |
|---------|---------|---------------|
| `bash: /mnt/c/.../.tmp-*.sh: No such file or directory` при том что `ls` файл видит | **CRLF** от Cursor Write: shebang = `bash\r` | **Всегда** `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../script.sh` (или `sed -i 's/\r$//' && bash`). **Не** голый `wsl -e bash /mnt/c/.../script.sh` |
| `unexpected EOF`, `ParserError`, пустой curl | PS ломает `"`, `$`, heredoc, `&&` | Файл `.sh` → `run-wsl-sh.sh` |
| `bash: syntax error near '('` | `grep -E ^(...)` в PS; **`fix(ci):` / `(…)` в `wsl -lc` one-liner** | Только внутри `.sh`; commit → `-F` файл (см. ниже) |
| `{{.Names}}: command not found` | `docker --format "{{…}}"` в PS | Формат в `.sh` или простой `docker ps` |
| `&&` is not valid | PowerShell не bash | Отдельные команды или `.sh` |
**Запрещено в PS:** голый `wsl -e bash /mnt/c/.../.tmp-*.sh` (без `run-wsl-sh.sh`), `wsl -e bash -lc "…\"…"`, `curl -d "{\"x\":…}"`, heredoc commit, `npm`/`rebar3`.
Канон запуска скриптов: см. `shell-wsl-not-powershell.mdc` § CRLF.
## 2. Git
| Симптом | Как правильно |
|---------|---------------|
| `#` в `-m` обрезает сообщение | `-F` файл; в тексте `Refs EventHub/EventHubBack#N` |
| `syntax error near '('` на commit | Сообщение `fix(ci): …` inline в `wsl -lc '… git-commit.sh … fix(ci): …'` | **Только** `.tmp-commit-msg.txt` + `git commit -F`; или `.sh` без inline `-m` |
| `Co-authored-by: Cursor <…>` / `<` ParserError | `git commit -F .tmp-msg.txt` из PS **или** `git-commit.sh` **из `.sh`**, не из PS one-liner |
| `editor` / not a terminal | Не `git commit` без `-F` в WSL; из Windows: `git commit -F file` |
| Сотни `M` после reset на `/mnt/c` | `git config core.filemode false` (локально); не «чинить» код |
| Удалились `.cursor/`, `.idea/` | **`git clean -fdx` только с backup** env + certs; IDE-директории **не трогать** |
| Push «висит» / ждёт login | **Не** `git push origin` — только `~/.cursor/eventhub/git-push.sh` / `git-push-main.sh` |
| Push заблокирован Auto-review | Push в `master` — только по явной просьбе; `request_smart_mode_approval` |
**`git clean` / reset к origin:** сначала сохранить `docker/.env*`, `.env.development`, `docker/traefik/certs/*`.
**Push:** запрещены голые `git push` / `git push origin …`. Канон — `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh <repo-dir>` (токен из secrets).
**Commit (сообщение со скобками, `#`, `$`, пробелами):** не передавать subject/body аргументом через PowerShell или `wsl -e bash -lc '…'`. Пиши `.tmp-commit-msg.txt` в репо, затем в `.sh`:
```bash
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
git add path/to/file
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
```
Запуск из PS: `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../EventHubBack/.tmp-commit.sh` — **без** голого `wsl -e bash …/.tmp-commit.sh`, **без** `-lc` и **без** `-m "fix(ci): …"`.
## 3. Docker dev (Swarm локально)
| Ошибка | Правильно |
|--------|-----------|
| «Балансировка» → 3× eventhub | **dev:** `traefik=1` + **`eventhub=1`** (см. `EventHubDevOps/docs/STANDS.md`) |
| `host-mode port already in use` | У eventhub опубликованы 8080/8445 — **нельзя** scale >1; LB = traefik |
| Собрали admin-ui/front без просьбы | «Только бэк» → eventhub (+ traefik если нужен HTTPS), **без** Front/Admin образов |
| Эмулятор не остановился | `docker service scale eventhub_bot-emulator-users=0` |
| `No such image: eventhub:latest` | Сначала `docker build -t eventhub:latest …` или `docker/build-images.sh` |
| Admin API dev не отвечает на :443 | Traefik без ports → **`http://127.0.0.1:8445`**; stage → SSH + `--resolve …:443:127.0.0.1` |
## 4. Scope (не додумывать)
- Делать **ровно** запрос; не раздувать (полный `build-images.sh`, 3 ноды, admin-ui, spec commit).
- Перед scale/deploy — прочитать compose и STANDS.md.
- Секреты: `gitea.ps1` / `.sh` + env; не inline `source secrets` в PS one-liner.
## 5. Субагенты (Task)
В промпт субагента добавлять:
```text
Shell: только WSL + .sh (см. shell-wsl-not-powershell.mdc, agent-pitfalls.mdc).
Один репо. Итог на русском. Не push без явного OK.
```
## 6. Экономия токенов / лимитов моделей
### Контекст чата
- **Один чат — одна задача**; после «готово / CI зелёный» — новый чат на следующий шаг (см. `WORKFLOW.md` §5).
- Workspace: для Back-задачи достаточно **`EventHubBack`** (+ Spec при необходимости); не держать все репо без нужды.
- **`@` точечно** — файл/папка, не `@Codebase` без причины.
- Не просить «проанализируй все transcripts» — они огромные.
### Дешёвые vs дорогие действия
| Дорого | Дешевле |
|--------|---------|
| Task explore «very thorough» | `grep` + 12 файла |
| Полный `build-images.sh` | Только нужный образ |
| Весь лог CI (80k строк) | API job + 20 строк вокруг ошибки |
| Несколько subagents «на всякий» | 1 subagent, узкий промпт |
| Browser deep-walk stage | Smoke-скрипт / один URL |
### Меньше итераций
- Сразу **`.sh`**, не 5 попыток PS one-liner (каждая = новый turn).
- Git перед commit: **один** `status` + `diff`, не три раза подряд.
- Тесты: `rebar3 eunit --module=…` если трогали один модуль; не полный suite без нужды.
- Docker: не rebuild, если `docker images` уже есть нужный tag.
### Модели и режимы
- **Ask / Plan** — вопросы и варианты без tool calls.
- **Agent** — только когда нужны команды и правки.
- Bugbot / Security Review — не на каждый мелкий diff.
### Scope в запросе (шаблон)
```text
Репо: EventHubBack
Issue: #N
Scope: только …
Не: front, docker, push, spec
Done: eunit / lint / …
```
### Subagents
- Редко; промпт = issue + файлы + done, **без** всей переписки.
- `one-repo-per-agent` — меньше контекста и ошибок.
### Повторяемое — в скрипты
Токены verify, smoke creds, stage deploy — **`~/.cursor/eventhub/*.sh`**, не заново через SSH в каждом чате.
---
description: Типичные ошибки агентов EventHub — shell, git, docker, scope, токены (корень + субагенты)
alwaysApply: true
---
# EventHub: типичные ошибки агентов (избегать)
Канон. Субагентам: включать в промпт «следуй `agent-pitfalls.mdc` + `shell-wsl-not-powershell.mdc`».
## 1. Shell / PowerShell
| Симптом | Причина | Как правильно |
|---------|---------|---------------|
| `bash: /mnt/c/.../.tmp-*.sh: No such file or directory` при том что `ls` файл видит | **CRLF** от Cursor Write: shebang = `bash\r` | **Всегда** `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../script.sh` (или `sed -i 's/\r$//' && bash`). **Не** голый `wsl -e bash /mnt/c/.../script.sh` |
| IFT e2e: **N skipped**, exit 0 | Нет `SMOKE_USER_*` (голый `npm run test:e2e:ift`) | `run-front-ift.sh` / source `eventhub-ift.env` **до** npm. Не путать со «стенд недоступен». CI уже крутит e2e-ift → локально не дублировать |
| `unexpected EOF`, `ParserError`, пустой curl | PS ломает `"`, `$`, heredoc, `&&` | Файл `.sh` → `run-wsl-sh.sh` |
| `bash: syntax error near '('` | `grep -E ^(...)` в PS; **`fix(ci):` / `(…)` в `wsl -lc` one-liner** | Только внутри `.sh`; commit → `-F` файл (см. ниже) |
| `{{.Names}}: command not found` | `docker --format "{{…}}"` в PS | Формат в `.sh` или простой `docker ps` |
| `&&` is not valid | PowerShell не bash | Отдельные команды или `.sh` |
**Запрещено в PS:** голый `wsl -e bash /mnt/c/.../.tmp-*.sh` (без `run-wsl-sh.sh`), `wsl -e bash -lc "…\"…"`, `curl -d "{\"x\":…}"`, heredoc commit, `npm`/`rebar3`.
Канон запуска скриптов: см. `shell-wsl-not-powershell.mdc` § CRLF.
## 2. Git
| Симптом | Как правильно |
|---------|---------------|
| `#` в `-m` обрезает сообщение | `-F` файл; в тексте `Refs EventHub/EventHubBack#N` |
| `syntax error near '('` на commit | Сообщение `fix(ci): …` inline в `wsl -lc '… git-commit.sh … fix(ci): …'` | **Только** `.tmp-commit-msg.txt` + `git commit -F`; или `.sh` без inline `-m` |
| `Co-authored-by: Cursor <…>` / `<` ParserError | `git commit -F .tmp-msg.txt` из PS **или** `git-commit.sh` **из `.sh`**, не из PS one-liner |
| `editor` / not a terminal | Не `git commit` без `-F` в WSL; из Windows: `git commit -F file` |
| Сотни `M` после reset на `/mnt/c` | `git config core.filemode false` (локально); не «чинить» код |
| Удалились `.cursor/`, `.idea/` | **`git clean -fdx` только с backup** env + certs; IDE-директории **не трогать** |
| Push «висит» / ждёт login | **Не** `git push origin` — только `~/.cursor/eventhub/git-push.sh` / `git-push-main.sh` |
| Push заблокирован Auto-review | Push в `master` — только по явной просьбе; `request_smart_mode_approval` |
**`git clean` / reset к origin:** сначала сохранить `docker/.env*`, `.env.development`, `docker/traefik/certs/*`.
**Push:** запрещены голые `git push` / `git push origin …`. Канон — `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh <repo-dir>` (токен из secrets).
**Commit (сообщение со скобками, `#`, `$`, пробелами):** не передавать subject/body аргументом через PowerShell или `wsl -e bash -lc '…'`. Пиши `.tmp-commit-msg.txt` в репо, затем в `.sh`:
```bash
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
git add path/to/file
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
```
Запуск из PS: `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../EventHubBack/.tmp-commit.sh` — **без** голого `wsl -e bash …/.tmp-commit.sh`, **без** `-lc` и **без** `-m "fix(ci): …"`.
## 3. Docker dev (Swarm локально)
| Ошибка | Правильно |
|--------|-----------|
| «Балансировка» → 3× eventhub | **dev:** `traefik=1` + **`eventhub=1`** (см. `EventHubDevOps/docs/STANDS.md`) |
| `host-mode port already in use` | У eventhub опубликованы 8080/8445 — **нельзя** scale >1; LB = traefik |
| Собрали admin-ui/front без просьбы | «Только бэк» → eventhub (+ traefik если нужен HTTPS), **без** Front/Admin образов |
| Эмулятор не остановился | `docker service scale eventhub_bot-emulator-users=0` |
| `No such image: eventhub:latest` | Сначала `docker build -t eventhub:latest …` или `docker/build-images.sh` |
| Admin API dev не отвечает на :443 | Traefik без ports → **`http://127.0.0.1:8445`**; stage → SSH + `--resolve …:443:127.0.0.1` |
## 4. Scope (не додумывать)
- Делать **ровно** запрос; не раздувать (полный `build-images.sh`, 3 ноды, admin-ui, spec commit).
- Перед scale/deploy — прочитать compose и STANDS.md.
- Секреты: `gitea.ps1` / `.sh` + env; не inline `source secrets` в PS one-liner.
## 5. Субагенты (Task)
В промпт субагента добавлять:
```text
Shell: только WSL + .sh (см. shell-wsl-not-powershell.mdc, agent-pitfalls.mdc).
Один репо. Итог на русском. Не push без явного OK.
```
## 6. Экономия токенов / лимитов моделей
### Контекст чата
- **Один чат — одна задача**; после «готово / CI зелёный» — новый чат на следующий шаг (см. `WORKFLOW.md` §5).
- Workspace: для Back-задачи достаточно **`EventHubBack`** (+ Spec при необходимости); не держать все репо без нужды.
- **`@` точечно** — файл/папка, не `@Codebase` без причины.
- Не просить «проанализируй все transcripts» — они огромные.
### Дешёвые vs дорогие действия
| Дорого | Дешевле |
|--------|---------|
| Task explore «very thorough» | `grep` + 12 файла |
| Полный `build-images.sh` | Только нужный образ |
| Весь лог CI (80k строк) | API job + 20 строк вокруг ошибки |
| Несколько subagents «на всякий» | 1 subagent, узкий промпт |
| Browser deep-walk stage | Smoke-скрипт / один URL |
### Меньше итераций
- Сразу **`.sh`**, не 5 попыток PS one-liner (каждая = новый turn).
- Git перед commit: **один** `status` + `diff`, не три раза подряд.
- Тесты: `rebar3 eunit --module=…` если трогали один модуль; не полный suite без нужды.
- Docker: не rebuild, если `docker images` уже есть нужный tag.
### Модели и режимы
- **Ask / Plan** — вопросы и варианты без tool calls.
- **Agent** — только когда нужны команды и правки.
- Bugbot / Security Review — не на каждый мелкий diff.
### Scope в запросе (шаблон)
```text
Репо: EventHubBack
Issue: #N
Scope: только …
Не: front, docker, push, spec
Done: eunit / lint / …
```
### Subagents
- Редко; промпт = issue + файлы + done, **без** всей переписки.
- `one-repo-per-agent` — меньше контекста и ошибок.
### Повторяемое — в скрипты
Токены verify, smoke creds, stage deploy — **`~/.cursor/eventhub/*.sh`**, не заново через SSH в каждом чате.
+35 -33
View File
@@ -1,33 +1,35 @@
---
description: EventHub — локальные сборки/тесты только через WSL (Windows toolchain сломан)
alwaysApply: true
---
# EventHub: рабочее окружение агента
На этой машине **не использовать** нативный Windows для:
| Стек | Нельзя (Windows) | Нужно |
|------|------------------|--------|
| Front / FrontAdmin | `npm`, `npx`, Playwright | **WSL** `npm` |
| Back (Erlang) | Windows `erl` / `rebar3` | **WSL** + `scripts/wsl-dev-env.sh` |
## Front (EventHubFront)
```bash
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront && npm run lint && npm run build && npm run test:e2e'
```
## FrontAdmin
```bash
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFrontAdmin && npm run lint && npm run build'
```
## Back
```bash
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack && source scripts/wsl-dev-env.sh && rebar3 eunit'
```
Подробности: `EventHubFront/.cursor/rules/npm-wsl.mdc`, `EventHubBack/.cursor/rules/otp-rebar-wsl.mdc`.
---
description: EventHub — локальные сборки/тесты только через WSL (Windows toolchain сломан)
alwaysApply: true
---
# EventHub: рабочее окружение агента
На этой машине **не использовать** нативный Windows для:
| Стек | Нельзя (Windows) | Нужно |
|------|------------------|--------|
| Front / FrontAdmin | `npm`, `npx`, Playwright | **WSL** `npm` |
| Back (Erlang) | Windows `erl` / `rebar3` | **WSL** + `scripts/wsl-dev-env.sh` |
## Front (EventHubFront)
```bash
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront && npm run lint && npm run build && npm run test:e2e'
```
## FrontAdmin
```bash
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFrontAdmin && npm run lint && npm run build'
```
## Back
```bash
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack && source scripts/wsl-dev-env.sh && rebar3 eunit'
```
Подробности: `EventHubFront/.cursor/rules/npm-wsl.mdc`, `EventHubBack/.cursor/rules/otp-rebar-wsl.mdc`.
Front IFT e2e: не голый `npm run test:e2e:ift` (получите skipped). Нужны `SMOKE_USER_*` — `run-front-ift.sh` / `eventhub-ift.env`.
+130 -130
View File
@@ -1,130 +1,130 @@
---
description: EventHub — команды только через WSL/bash; PowerShell для сложного запрещён
alwaysApply: true
---
# EventHub: как запускать команды (обязательно)
## Проблема
PowerShell в Cursor **ломает** кавычки, `$`, JSON, heredoc и вложенный `bash -lc "..."`. Из‑за этого агенты снова и снова получают `unexpected EOF`, `Invalid JSON`, пустой curl.
## Правило
1. **Нельзя** гонять сложные one-liner’ы в PowerShell (npm, curl+JSON, python -c, git commit heredoc, вложенный bash с кавычками).
2. **Можно** в PowerShell только простые вещи: `cd`, `git status`, `ls`, одиночный `wsl …` без вложенных кавычек в аргументе.
3. Рабочий способ — **WSL bash** или **файл `.sh` + launcher** (см. ниже).
## CRLF / «No such file or directory» (обязательно)
Cursor `Write` на Windows часто сохраняет `.sh` с **CRLF**. Тогда:
```text
wsl -e bash /mnt/c/.../EventHubFront/.tmp-run.sh
# bash: .../.tmp-run.sh: No such file or directory
```
Файл **есть** (`ls` его видит) — ломается shebang (`#!/usr/bin/env bash\r`). Это **не** «файл не записался».
### Канон запуска любого `.sh` с `/mnt/c/...`
**Всегда** через хелпер (снимает `\r`, потом `bash`):
```text
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront/.tmp-run.sh
```
**Запрещено** после `Write` голый:
```text
wsl -e bash /mnt/c/.../repo/.tmp-something.sh
```
Альтернатива без хелпера (тот же смысл):
```text
wsl -e bash -lc 'sed -i "s/\r$//" /mnt/c/.../script.sh && bash /mnt/c/.../script.sh'
```
Предпочтительно класть долгоживущие скрипты в `/mnt/c/Users/alexc/.cursor/eventhub/` и тоже гонять через `run-wsl-sh.sh`.
## Канон (копируй)
### Вариант A — скрипт (предпочтительно)
1. Запиши команды в файл, например `EventHubBack/.tmp-run.sh` или `~/.cursor/eventhub/….sh` (`#!/usr/bin/env bash`).
2. Запусти **только** так (из PowerShell, без вложенных кавычек):
```text
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-run.sh
```
### Вариант B — один простой WSL вызов
Только если внутри **одинарные** кавычки bash и нет `"`/`$` конфликтов с PS (и **нет** нового `.sh` с диска C):
```text
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront && npm run lint'
```
В PowerShell для `-lc` используй **одинарные** кавычки снаружи (`'...'`), внутри — обычный bash.
### Запрещено
```text
wsl -e bash /mnt/c/.../.tmp-run.sh # без run-wsl-sh / sed — CRLF → No such file
wsl -e bash -lc ".... python -c \"import...\" ...." # ломается
curl ... -d "{\"email\":\"$x\"}" # в PS ломается
git commit -m "$(cat <<'EOF' ...)" # в PS не так
wsl -e bash -lc 'git-commit.sh … fix(ci): subject' # ( ) ломают bash
wsl -e bash -lc 'git commit -m "fix(ci): …"' # то же
npm / npx / node из Windows # сломан
```
### Git commit — канон (subject с `( )`, `#`, `$`)
1. Текст коммита — в файл репо, напр. `.tmp-commit-msg.txt` (без heredoc из PS).
2. Скрипт `.tmp-commit.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
git add path1 path2
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
```
3. Из PowerShell:
```text
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-commit.sh
```
**Не** передавать сообщение аргументом в `git-commit.sh` / `-m` через `wsl -lc` — скобки в `fix(ci):` парсятся как subshell.
## Стек → команда
| Задача | Как |
|-----|-----|
| Front lint/build/e2e | `.sh` + `run-wsl-sh.sh` или `-lc` с PATH-fix + `npm …` |
| Front перед push main | сначала `npm run test:e2e:ift` в WSL, потом `git-push-main.sh` |
| Back rebar/eunit | `source scripts/wsl-dev-env.sh` в WSL |
| Git commit/push | **commit:** `.tmp-commit-msg.txt` + `git commit -F` в `.sh` через `run-wsl-sh.sh`; **push:** `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh …` |
| JSON/API/python | скрипт `.sh`/`.py` + `run-wsl-sh.sh` |
## Git push (обязательно)
**Запрещено агентам:** `git push`, `git push origin`, `git push origin HEAD` / `master` напрямую — без токена зависают на HTTPS prompt.
**Канон:**
```text
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push-main.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront
```
Путь WSL: `/mnt/c/Users/alexc/.cursor/eventhub/` (не `~/.cursor/…` — home в WSL может отличаться).
Credential store в WSL уже настроен (`credential.helper=store` + `git.sabilin.com`); хелпер всё равно предпочтителен (явный URL с токеном из secrets).
## Перед push Front
1. WSL: `npm run lint && npm run build` (ловит синтаксис/TS)
2. WSL: `npm run test:e2e:ift`
3. Только потом push через `git-push-main.sh`. **Не пушить**, если lint/build/IFT красные.
## Частые сбои (подробнее)
См. **`agent-pitfalls.mdc`**: CRLF/`run-wsl-sh.sh`, git `-F`, `&&` в PS, docker dev scale, `git clean` vs `.cursor/`.
---
description: EventHub — команды только через WSL/bash; PowerShell для сложного запрещён
alwaysApply: true
---
# EventHub: как запускать команды (обязательно)
## Проблема
PowerShell в Cursor **ломает** кавычки, `$`, JSON, heredoc и вложенный `bash -lc "..."`. Из‑за этого агенты снова и снова получают `unexpected EOF`, `Invalid JSON`, пустой curl.
## Правило
1. **Нельзя** гонять сложные one-liner’ы в PowerShell (npm, curl+JSON, python -c, git commit heredoc, вложенный bash с кавычками).
2. **Можно** в PowerShell только простые вещи: `cd`, `git status`, `ls`, одиночный `wsl …` без вложенных кавычек в аргументе.
3. Рабочий способ — **WSL bash** или **файл `.sh` + launcher** (см. ниже).
## CRLF / «No such file or directory» (обязательно)
Cursor `Write` на Windows часто сохраняет `.sh` с **CRLF**. Тогда:
```text
wsl -e bash /mnt/c/.../EventHubFront/.tmp-run.sh
# bash: .../.tmp-run.sh: No such file or directory
```
Файл **есть** (`ls` его видит) — ломается shebang (`#!/usr/bin/env bash\r`). Это **не** «файл не записался».
### Канон запуска любого `.sh` с `/mnt/c/...`
**Всегда** через хелпер (снимает `\r`, потом `bash`):
```text
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront/.tmp-run.sh
```
**Запрещено** после `Write` голый:
```text
wsl -e bash /mnt/c/.../repo/.tmp-something.sh
```
Альтернатива без хелпера (тот же смысл):
```text
wsl -e bash -lc 'sed -i "s/\r$//" /mnt/c/.../script.sh && bash /mnt/c/.../script.sh'
```
Предпочтительно класть долгоживущие скрипты в `/mnt/c/Users/alexc/.cursor/eventhub/` и тоже гонять через `run-wsl-sh.sh`.
## Канон (копируй)
### Вариант A — скрипт (предпочтительно)
1. Запиши команды в файл, например `EventHubBack/.tmp-run.sh` или `~/.cursor/eventhub/….sh` (`#!/usr/bin/env bash`).
2. Запусти **только** так (из PowerShell, без вложенных кавычек):
```text
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-run.sh
```
### Вариант B — один простой WSL вызов
Только если внутри **одинарные** кавычки bash и нет `"`/`$` конфликтов с PS (и **нет** нового `.sh` с диска C):
```text
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront && npm run lint'
```
В PowerShell для `-lc` используй **одинарные** кавычки снаружи (`'...'`), внутри — обычный bash.
### Запрещено
```text
wsl -e bash /mnt/c/.../.tmp-run.sh # без run-wsl-sh / sed — CRLF → No such file
wsl -e bash -lc ".... python -c \"import...\" ...." # ломается
curl ... -d "{\"email\":\"$x\"}" # в PS ломается
git commit -m "$(cat <<'EOF' ...)" # в PS не так
wsl -e bash -lc 'git-commit.sh … fix(ci): subject' # ( ) ломают bash
wsl -e bash -lc 'git commit -m "fix(ci): …"' # то же
npm / npx / node из Windows # сломан
```
### Git commit — канон (subject с `( )`, `#`, `$`)
1. Текст коммита — в файл репо, напр. `.tmp-commit-msg.txt` (без heredoc из PS).
2. Скрипт `.tmp-commit.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
git add path1 path2
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
```
3. Из PowerShell:
```text
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-commit.sh
```
**Не** передавать сообщение аргументом в `git-commit.sh` / `-m` через `wsl -lc` — скобки в `fix(ci):` парсятся как subshell.
## Стек → команда
| Задача | Как |
|-----|-----|
| Front lint/build/e2e | `.sh` + `run-wsl-sh.sh` или `-lc` с PATH-fix + `npm …` |
| Front перед push main | сначала `npm run test:e2e:ift` в WSL, потом `git-push-main.sh` |
| Back rebar/eunit | `source scripts/wsl-dev-env.sh` в WSL |
| Git commit/push | **commit:** `.tmp-commit-msg.txt` + `git commit -F` в `.sh` через `run-wsl-sh.sh`; **push:** `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh …` |
| JSON/API/python | скрипт `.sh`/`.py` + `run-wsl-sh.sh` |
## Git push (обязательно)
**Запрещено агентам:** `git push`, `git push origin`, `git push origin HEAD` / `master` напрямую — без токена зависают на HTTPS prompt.
**Канон:**
```text
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push-main.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront
```
Путь WSL: `/mnt/c/Users/alexc/.cursor/eventhub/` (не `~/.cursor/…` — home в WSL может отличаться).
Credential store в WSL уже настроен (`credential.helper=store` + `git.sabilin.com`); хелпер всё равно предпочтителен (явный URL с токеном из secrets).
## Перед push Front
1. WSL: `npm run lint && npm run build` (ловит синтаксис/TS)
2. WSL: `npm run test:e2e:ift`
3. Только потом push через `git-push-main.sh`. **Не пушить**, если lint/build/IFT красные.
## Частые сбои (подробнее)
См. **`agent-pitfalls.mdc`**: CRLF/`run-wsl-sh.sh`, git `-F`, `&&` в PS, docker dev scale, `git clean` vs `.cursor/`.
+5 -2
View File
@@ -1,2 +1,5 @@
/.idea/
/EventHubSpec.iml
/.idea/
/EventHubSpec.iml
# Temporary/scratch files
.tmp-*
+115
View File
@@ -0,0 +1,115 @@
# Архив календаря (Фаза 3, волна 1)
Канон хранения и чтения истории. Трекер: [Spec#20](https://git.sabilin.com/EventHub/EventHubSpec/issues/20), эпик [Spec#19](https://git.sabilin.com/EventHub/EventHubSpec/issues/19).
Реализация: Back#76, Front#73. Этот документ — контракт; код подтягивается после него.
## Цель
Горячая Mnesia и live-запросы **не растут с годами истории**. Прошлые месяцы остаются usable в том же React-grid, без iframe как основного UX.
Критерий выигрыша: размер и нагрузка **рабочей** БД (dump/checkpoint, репликация `disc_copies`, `index_read` календаря), не «ещё одна копия тех же строк в другой таблице/ноде».
## Почему не старый pipeline (#15)
Старый pipeline (`archive_controller` / `archive_manager` / extra BEAM / HTML `GET …/view`):
- не планировался и не переносил данные (типы дат, схема `#event{}` разъехалась);
- extra BEAM на день/месяц — **больше** схем и load, не меньше;
- HTML `/view` и renderer **удалены**: история только через JSON `GET …/events`.
Extra-node, `slave`/`peer` для архива и построчный `event_archive` **удалены** (не «мёртвый» код).
## Модель: три яруса, ключ месяц
Единица архива: **календарь × `YYYY-MM`**. Не строка события.
| Ярус | Где | Рост | Кто читает |
|------|-----|------|------------|
| **Hot** | `event`, `booking` (`disc_copies`) | **текущий календарный месяц + всё будущее** | живой grid; выборка по `from`/`to`, не весь календарь |
| **Warm** | `month_snapshot` (`disc_only_copies`), ключ `{calendar_id, year_month}` | **3 закрытых месяца** gzip JSON | частый flip на прошлый месяц — один lookup |
| **Cold** | `{UPLOAD_DIR}/archive/{calendar_id}/{YYYY-MM}.json.gz` | диск volume, не Mnesia | тот же JSON API |
### Оценка нагрузки (зачем не 90 дней)
Живые запросы Front:
| Путь | Окно |
|------|------|
| Month grid | ~42 дня viewport (хвосты соседних месяцев) |
| Week / ghost | 714 дней |
| Time Arc hints | сегодня + **21 день** вперёд |
| Day | 1 день |
90 суток lookback в hot **не обслуживает** ни один live-путь: прошлый календарный месяц в UI — уже «архив». Пока строки висят в `event`, `dirty_index_read(calendar_id)` (как сейчас) тащит их на каждый `GET /events`.
Оценка busy-студии (8 слотов × 8 мастеров ≈ 64 события/день), **только lookback** (будущее всё равно в hot):
| Lookback | Лишние строки в hot на календарь |
|----------|----------------------------------|
| 90 суток | ~5800 |
| текущий месяц (~17–31 день) | ~11002000 |
| закрытый месяц после grace | **0** исторических месяцев |
100 таких студий: 90д lookback ≈ **0.5M** лишних hot-строк в `disc_copies` на **каждый** узел кластера. Warm-blob 24 месяцев в Mnesia тоже тяжёлый (~1 MB gzip/мес × 24 × 100 ≈ 2GB disc_only). Три месяца warm ≈ **300MB** на ту же сотню.
### Дефолты (зафиксировано)
- **Hot:** все события с `start_time` в **текущем календарном месяце** (TZ календаря / UTC — в реализации, один канон) **или в будущем**.
- **Снимок месяца M:** когда наступило **7 суток** следующего месяца (`ARCHIVE_GRACE_DAYS=7`) — хвост week/month-grid и правки конца месяца; затем M **read-only**.
- **Warm:** последние **3 закрытых месяца** blob в Mnesia (`ARCHIVE_WARM_MONTHS=3`).
- **Cold:** старше 3 закрытых месяцев, **без TTL**.
- `GET …/events?from&to` **склеивает ярусы**: viewport августа 1-го числа читает хвост июля из warm/cold + август из hot.
- Recurring **master** с вхождениями в hot/будущем остаётся в hot; в snapshot — только вхождения закрытого месяца.
- **Booking** остаётся в hot (inbox `/bookings`); в snapshot — копия занятости на момент снимка (через `event_to_json`).
- Mutate в archived month → `409`. Reopen — не в волне 1.
Env (без смены контракта): `ARCHIVE_GRACE_DAYS`, `ARCHIVE_WARM_MONTHS`.
```
Hot --(месяц закрыт + 7д grace)--> Warm snapshot --(>3 закрытых мес)--> Cold file
```
## API чтения (контракт Back#76)
Клиент **не** переключается на HTML для прошлого месяца.
- Живой диапазон: `GET /v1/calendars/:id/events?from&to` — hot по времени; если `from`
задевает закрытый месяц — **merge** warm/cold (хвост month-grid). Не отдавать весь
календарь без `from`/`to` как единственный путь live-grid.
- Прошлый месяц (и week/day, курсор в archived month): тот же path и shape JSON. Сервер сам берёт warm snapshot или cold file и отдаёт события, попадающие в `from`/`to`.
- Ответ может содержать флаг `archived: true` на уровне списка или заголовка/обёртки — чтобы Front показал бейдж «Архив». Точная форма — в реализации, без второго URL.
- `GET /v1/calendars/:id/view` **нет**: HTML-календарь снят.
ACL: как у живого календаря — owner **или** share `read|write|admin` (кто уже `can_access`). Не только owner. Guest / public commercial: только hot public events; deep archive чужой студии — **не** в волне 1.
## Запись
- Create/update/delete событий и booking — только hot.
- Scheduler (один узел, как `infra_cleanup`): закрыть месяц → собрать JSON → gzip → `month_snapshot` → удалить из hot строки этого месяца (кроме защищённых master). Затем по TTL — сбросить blob в файл, ужать/убрать запись Mnesia до pointer.
- Идемпотентность: повторный прогон того же `{calendar_id, YYYY-MM}` не дублирует и не теряет данные.
- Backup (DevOps): volume `eventhub-data` включает `UPLOAD_DIR/archive/`.
## Front (контракт Front#73)
- Owner/share, вид месяц/неделя/день, **прошедший** период: React-grid по JSON, не iframe.
- Бейдж «Архив».
- Lens overview на прошлом месяце — минимум (не «нет lens, потому что HTML»).
- Текущий и будущий месяц без регрессии.
- Time Arc / ghosts на истории — **не** в волне 1.
## Non-goals волны 1
- Write в warm/cold, reopen месяца.
- Гостевой архив чужой студии.
- Time Arc на прошлых месяцах.
- S3 / внешняя БД / extra Erlang node.
- Удаление истории по TTL (файлы храним).
- Починка `archive_controller`/`slave` как целевой путь.
## Критерии приёмки канона
- [x] Этот документ согласован (Spec#20).
- [ ] Back#76: scheduler + snapshot + JSON read + тесты; peer/slave не в hot path.
- [ ] Front#73: past period на React; e2e навигации на прошлый месяц.
- [ ] После тестов: BackSpec/FrontSpec сверены с кодом (не с этим черновиком API-флага).
+12 -9
View File
@@ -33,17 +33,20 @@ IA без изменений: top nav + bottom nav mobile; без left sidebar;
Runtime-копии в приложении: `EventHubFront/public/brand/` (mark, lockup, favicons, mood app icons).
Локальные ZIP (`calentiq-brand-pack-local.zip` и т.п.) — рабочие сборки; в git SoT не кладём.
## Domain / DNS (чеклист, без покупки)
## Domain / DNS (чеклист, `calentiq.com`)
Публичный бренд ≠ внутренние хосты `*.eventhub.local` / `*.eventhub.test` (DevOps). Перед продом:
Публичный бренд ≠ внутренние хосты `*.eventhub.local` / `*.eventhub.test` (DevOps).
**Канон apex:** `calentiq.com` (куплен, в использовании на stage / IFT / redirects).
- [ ] Выбрать домен (кандидат: `calentiq.*` / аналог) — **не покупать** без явного ок владельца
- [ ] RDAP/whois: свободен ли домен; нет ли конфликтующих TM
- [ ] DNS: A/AAAA или CNAME на edge (Traefik/CDN); отдельно `www` / apex
- [ ] TLS: ACME (Lets Encrypt) или managed cert; проверить SAN
- [ ] Почта: SPF/DKIM/DMARC когда будет SMTP (серия B2)
- [ ] Stage/IFT: либо оставить `eventhub.*` стенды, либо завести `*.calentiq.*` aliases в DevOps
- [ ] Обновить публичные URL в UI/письмах только после cutover
Проверка 2026-08-16 (публичный DNS + HTTPS):
- [x] Домен: `calentiq.com` (куплен, в проде/stage/IFT)
- [x] Регистрация: домен занят нами (NS `nameself` / regtime); TM-конфликты — вне DNS-чеклиста
- [x] DNS: A `calentiq.com` / `www` / `stage.*` / `api.stage.*``195.208.119.190`; apex+www → 308 `https://stage.calentiq.com/`; IFT — split-DNS (`EventHubDevOps/docs/CALENTIQ-CERTS.md`, `STANDS.md`)
- [x] TLS: Lets Encrypt (Traefik `certResolver: le`); SAN stage/IFT живые (issuer LE YR2)
- [x] Почта: SPF/DKIM/DMARC для Resend (канон stage) — статус в `EventHubDevOps/docs/SMTP.md`
- [x] Stage/IFT aliases `*.calentiq.com` — в DevOps (`STANDS.md`, `CALENTIQ-CERTS.md`)
- [x] Публичные URL: `PUBLIC_APP_URL` / `SMTP_FROM=noreply@calentiq.com`; Front same-origin + legal stubs `/privacy` `/terms`
## Epic
+169 -39
View File
@@ -13,8 +13,9 @@ EventHub — платформа для управления событиями
### 2.1. Календари
- CRUD календаря (название, описание, теги, владелец)
- Расшаривание по ссылке / приглашения с правами (`calendar_share`) — **фаза 2** (таблица есть;
user HTTP API и UI — Future, см. §2.1.3)
- Расшаривание по ссылке / приглашения с правами (`calendar_share`) — invite/accept/revoke +
ACL `read|write|admin` (Back#73); personal и commercial (заместитель); UI — Front;
не путать со `specialist_invite` / follow.
- Типы календарей: `personal` | `commercial` — семантика и коммерческий контур: **§2.1.2**
- Гибкое подтверждение заявок: `auto` | `manual` | `{timeout, N}` (секунды) — детали §2.1.2 / §2.3
- Теги календаря, рейтинг (средняя оценка, количество голосов)
@@ -46,6 +47,8 @@ EventHub — платформа для управления событиями
- `default_duration_minutes` — integer 1..1440
- `default_recurrence``null` или
`{ "enabled": boolean, "freq": "DAILY"|"WEEKLY"|"MONTHLY", "interval": integer ≥ 1 }`
- `waitlist_enabled` — boolean (только смысл для commercial; default `false`):
лист ожидания на полных слотах (Back#72)
- Невалидное значение известного ключа → **400** `{error: "invalid_settings", key: "…"}`
(см. EventHub/EventHubBack#63, UI: EventHub/EventHubFront#44)
@@ -66,7 +69,7 @@ EventHub — платформа для управления событиями
| | `personal` | `commercial` |
|---|---|---|
| Подписка владельца | не требуется | нужна active (trial или paid), иначе **restricted** |
| Видимость чужим | только владелец (+ share — фаза 2) | публичный просмотр; в search/discover — только если **не** restricted |
| Видимость чужим | только владелец или share grant | публичный просмотр; в search/discover — только если **не** restricted |
| Booking чужим | **запрещён** (бэкенд `403`, код `personal_calendar`) | разрешён при active подписке владельца |
| Follow | нет (нет доступа) | да |
| Специалисты | не используются | CRUD владельца; `specialist_id` на событии |
@@ -135,6 +138,22 @@ Trial стартует **только** явным `POST /v1/subscription` с `a
no-op успех (как для `cancelled`).
- WS: `booking_update` участнику и владельцу (и specialist при confirm на «своём» событии).
#### Лист ожидания (commercial, опционально)
Включается флагом `settings.waitlist_enabled=true` на commercial-календаре
(default `false`; personal игнорируется).
- `POST /v1/events/:id/waitlist` — встать в очередь: commercial, `booking_open`,
`waitlist_enabled`, слот полный (`pending`+`confirmed` ≥ capacity), нет своей
активной booking, ещё не в очереди. Иначе `400`: `waitlist_disabled` |
`not_full` | `already_booked` | `already_on_waitlist` | …
- `DELETE /v1/events/:id/waitlist` — выйти из очереди (`left`); `404` если не в waiting.
- `GET /v1/events/:id/waitlist` — гость: `{enabled, joined, position?, total}`;
owner/specialist: список waiting (FIFO по `created_at`).
- **Promote:** при освобождении места (cancel / decline / expire / timeout-cancel)
первый `waiting` → booking по политике `confirmation` календаря; статус entry
`promoted`; email + in-app `waitlist_promoted`.
#### Специалисты
- Специалист = существующий `user`, привязанный к commercial-календарю
@@ -224,13 +243,58 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
по тем же правилам (owner — все события своих календарей; specialist — только свои слоты);
past-pending → `expired` и из ответа исключается.
#### Фаза 2 (вне текущего контракта реализации)
- `calendar_share` (`read` | `write` | `admin`): не путать с follow, specialist_invite и платной subscription.
- Реальный эквайринг, waitlist, оплата услуги клиентом (B2C).
#### Фаза 2 (вне текущего контракта реализации; трекер Spec#8)
- **Pilot / ops:** SMTP / transactional email; backup; secrets / certs / alerts; legal stubs;
позиция и боевой эквайринг **подписки владельца** (не оплата услуги клиентом); DNS/SPF
для `calentiq.com` (домен в использовании; чеклист `BRANDING.md`).
- Waitlist на событие — **сделано** (Back#72): таблица `waitlist_entry`;
API `POST/DELETE/GET /v1/events/:id/waitlist`; только commercial с
`settings.waitlist_enabled=true`; join при полном слоте; FIFO promote при
cancel/decline/expire + email/in-app `waitlist_promoted`. UI join — Front#70.
- Серверный logout / revoke session; загрузка файлов avatar/cover —
**сделано** (Back#71); вложения к событию — позже.
- Email-reminder booking — **сделано** (Back#70, §2.6); Web Push + prefs —
**сделано** (Back#75): `GET/PUT /v1/notifications/prefs`,
`GET /v1/push/vapid-public-key`, `POST/DELETE /v1/push/subscriptions`;
UI — Front#71.
- `calendar_share` (`read` | `write` | `admin`): соредактор personal / заместитель commercial;
invite/accept/revoke + grants; personal **не** в search/discovery. Не путать с follow,
specialist_invite и платной subscription.
- Управление своими сессиями + `client_type`/`device_name`**сделано** (Back#74):
`GET/DELETE /v1/sessions`, `POST /v1/sessions/revoke-others`; login принимает
`client_type` (`web`|`mobile`) и `device_name`, сохраняет `User-Agent`.
UI списка — Front#72. Kick-on-login / нативный клиент — по-прежнему future.
- **Не в фазе 2:** оплата услуги клиентом (B2C) — вне горизонта продукта.
### 2.1.3. Share / приглашения (фаза 2)
Таблица `calendar_share` (`calendar_id`, `user_id`, `rights`: `read` | `write` | `admin`)
существует в схеме. User HTTP API и клиентский UI — Future до отдельной задачи.
#### Фаза 3 / архив (Spec#19, канон Spec#20)
- История календаря: hot = текущий месяц + будущее; warm = 3 закрытых месяца;
cold = gzip-файлы. Контракт: [`ARCHIVE.md`](ARCHIVE.md). Код: Back#76.
#### Фаза 3 / гео (Spec#19, канон Spec#21)
- Стек: OpenCage (внешний API, прокси через бэк) + MapLibre/OpenFreeMap; Google/Яндекс **не** провайдеры
(только outbound-ссылки с карточки). Контракт: [`GEO.md`](GEO.md). Код: Back#77.
### 2.1.3. Share / приглашения (соредактор и заместитель)
Таблица `calendar_share` (`id`, `calendar_id`, `user_id`, `rights`: `read` | `write` | `admin`,
`mirror_to_default`) и `calendar_share_invite` (зеркало specialist_invite + `rights`).
- Invite создаёт **owner** или share `admin` (personal **и** commercial).
- Accept → upsert grant; для personal `mirror_to_default` default `true` (выбор invitee);
commercial mirror игнорируется / false.
- ACL: `can_access` personal = owner | share; commercial public view + share edit;
`can_edit` = owner | share `write|admin`. DELETE calendar / смена type — только owner.
- `GET /v1/calendars` = owned shared (`role`, `share_rights`, `mirror_to_default`).
- Personal **никогда** не discoverable в `/v1/search`.
API:
- `GET/POST /v1/calendars/:id/share-invites`, `DELETE …/:invite_id`
- `GET /v1/calendars/:id/shares`, `DELETE/PUT …/shares/:user_id`
- `PUT /v1/user/shares/:calendar_id` `{mirror_to_default}`
- `GET /v1/user/share-invites`, `POST /v1/share-invites/:id/accept|decline`,
`POST /v1/share-invites/accept` `{token}`
Не путать со `specialist_invite` (booking team) и `calendar_follow` (публичный follow).
### 2.2. События (расширенная версия с повторяющимися событиями)
@@ -341,16 +405,36 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
- Полнотекстовый поиск по названиям событий, календарей, тегам (при наличии `q` или любого
фильтра из списка выше).
- Фильтрация по дате, категории, местоположению, рейтингу.
- **Гео (волна 2, [GEO.md](GEO.md)):** `lat`+`lon` выключают discovery-tops. События —
по `event.location`; commercial-календари — по `settings.default_location`.
`radius` **опционален** (1..100 км); без ключа — без отсечения, сортировка `distance`.
В ответе `distance_km`. Без координат сущность в geo-выборку не входит.
- В элементе `calendars` поле `default_location` (`address`/`lat`/`lon`, либо `null`)
возвращается всегда, если у календаря задана точка (Back#77) — вид карты в поиске
строится и без гео-запроса. `distance_km` присутствует только при `lat`+`lon` в запросе.
- Пагинация результатов (`limit`, `offset`; default `limit=20`, max `100`).
- Поиск должен учитывать права доступа (не показывать скрытые/заблокированные календари).
### 2.6. Расширенные возможности
- Локация события (`location` запись с адресом, широтой, долготой).
- Локация события (`location` запись с адресом, широтой, долготой). Address-only
допустим; lat/lon оба или ни одного. Геокодер: [`GEO.md`](GEO.md)
(`GET /v1/geo/suggest`, `POST /v1/geo/geocode`, `POST /v1/geo/reverse`).
- Онлайн-ссылка (`online_link`).
- Вложения к событию (`attachments`).
- История изменений события (`edit_history`).
- Заметки пользователя к бронированию (`notes`).
- Напоминания о событии (поле `reminder_sent` в бронировании, логика отправки не реализована).
- Напоминания о событии: поле `reminder_sent` в бронировании; job
`logic_booking:process_reminders/0` (тик `subscription_worker`, ~15s) шлёт email
клиенту записи и in-app `event_reminder`, если `status=confirmed`,
`reminder_sent=false`, событие `active` и старт в окне
`REMINDER_LEAD_HOURS` (default 24). Флаг ставится до отправки (один раз).
Шаблон CalenTIQ; ссылка `/c/:calendarId/e/:eventId`.
Prefs: `user.preferences.notify_email` / `notify_push` (default true);
`GET/PUT /v1/notifications/prefs``{ email, push }`.
Web Push (Back#75): таблица `push_subscription`; VAPID env
`VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT`; send на reminder
и waitlist promote (если `notify_push`); 404/410 удаляет subscription.
Verify / invite / reset password — email всегда (не через prefs).
### 2.7. Модерация и безопасность
- Пользователи могут отправлять жалобы (`report`) на календари, события, отзывы.
@@ -379,7 +463,12 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
- Пользователи могут оформить подписку (`subscription`) с разными планами: monthly, quarterly,
biannual, annual.
- Цены (`logic_subscription:plan_price/1`, minor units): monthly **999**, quarterly **2499**,
biannual **4499**, annual **7999**; trial — 0. Платёжный шлюз — заглушка (`process_payment` → ok).
biannual **4499**, annual **7999**; trial — 0.
- **Пилот (Spec#18, вариант A):** боевого PSP **нет**. `process_payment` — заглушка → ok.
Владелец получает commercial через: (1) `POST /v1/subscription` `action=start_trial`,
и/или (2) admin activate (`POST /v1/admin/subscriptions/:id`). Реальный эквайринг
подписки владельца — отдельная задача после пилота (не блокер soft-launch).
- **Вне горизонта:** оплата услуги клиентом (B2C) — не в фазе 2 / не в пилоте.
- Статус подписки: `active`, `expired`, `cancelled`.
- Отслеживание использования пробного периода (`trial_used`); trial стартует **только**
явным `POST /v1/subscription` (`action=start_trial`), не при create/upgrade commercial
@@ -439,7 +528,9 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
- Полная репликация горячих таблиц между всеми узлами кластера (задача #14).
- Автоматическое обнаружение узлов через DNS-имя `eventhub-node` или статический список (задача #14).
- Периодическая очистка «мёртвых» узлов из схемы Mnesia (каждые 30 секунд) (задача #14).
- Архивирование исторических данных (старше 30 дней) в отдельные Mnesia-узлы с `disc_only_copies` (задача #15).
- Архив календаря (Фаза 3, [ARCHIVE.md](ARCHIVE.md), Spec#20): hot `disc_copies`
(текущий месяц + будущее) + `month_snapshot` `disc_only` (3 закрытых месяца) +
gzip-файлы старше. Не extra-node и не построчный `event_archive` (#15 отвергнут).
- Пагинация всех списков.
### 3.2. Надёжность
@@ -454,7 +545,8 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
- `EVENTHUB_ENV`: `dev` — допускаются documented fallbacks; `stage`/`prod` — fail-fast при слабых или отсутствующих `JWT_SECRET`, `ADMIN_JWT_SECRET` и паролях seed-админов (#25).
- Проверка прав доступа к календарям и событиям.
- Пароли хэшируются с использованием Argon2.
- Защита от несанкционированного просмотра архивных данных (только владелец календаря) (задача #15).
- Архив месяца: тот же ACL, что у живого календаря (`can_access`: owner | share).
Guest/public deep-archive чужой студии — не в волне 1 ([ARCHIVE.md](ARCHIVE.md)).
### 3.4. Наблюдаемость
- Логирование в JSON-формате.
@@ -483,12 +575,10 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
```
src/
├── core/ — бизнес-логика (core_user, core_event, core_booking, core_review, ...)
├── handlers/ — обработчики HTTP (handler_login, handler_calendar_view, ...)
├── infra/ — инфраструктура (infra_mnesia, infra_sup, cluster_discovery,
archive_manager, archive_controller, stats_collector,
│ migration_engine, ...)
├── archive/ — архивирование и рендеринг (archive_controller, archive_manager,
│ calendar_html_renderer, archive_fetcher)
├── handlers/ — обработчики HTTP (handler_login, handler_events, ...)
├── infra/ — infra_mnesia, infra_sup, month_archive_worker, stats_collector,
migration_engine, ...
├── logic/ — в т.ч. logic_month_archive (снимки месяца)
├── migrations/ — файлы миграций
└── eventhub_app.erl — точка входа приложения
```
@@ -500,11 +590,23 @@ src/
- `POST /v1/register` — регистрация.
- `POST /v1/verify` — подтверждение email; при успехе — `active` и дефолтный personal-календарь.
- `POST /v1/forgot-password``{email}` → всегда `200` (без enumeration); письмо со сбросом
только для `active` (stub `logic_email:send_password_reset/2`, как verification).
только для `active`. Почта: `logic_email` через SMTP (`SMTP_HOST` и др.); без хоста —
log-only. HTML+plain шаблоны: `priv/email/layout.html` (CalenTIQ). Ссылки: `PUBLIC_APP_URL` + `/verify?token=`,
`/invites?token=`, `/reset-password?token=` (бренд CalenTIQ).
- `POST /v1/reset-password``{token, password}` (пароль ≥ 8 символов); успех → новый hash,
токен удалён, refresh-сессии user отозваны; `404`/`410`/`400`/`403`.
- `POST /v1/login` — вход.
- `POST /v1/refresh` — обновление токена.
- `POST /v1/login` — вход; опционально `client_type` (`web`|`mobile`, default `web`),
`device_name` (max 120); `User-Agent` пишется в сессию. Ответ:
`{ token, refresh_token, session_id, user }`. Неверный `client_type``400`.
- `POST /v1/refresh` — обновление токена; ответ `{ token, refresh_token, session_id }`.
- `POST /v1/logout``{ refresh_token }` → отзыв текущей `auth_session`; после этого
refresh той же сессии → `401`. Access JWT до истечения TTL не отзывается.
Невалидный refresh → `401`; отсутствие поля → `400`. Клиент чистит storage даже при ошибке сети.
- `GET /v1/sessions` — список активных своих user-сессий (Bearer):
`session_id`, `client_type`, `device_name`, `user_agent`, `created_at`, `updated_at`, `expires_at`.
- `DELETE /v1/sessions/:id` — отзыв своей сессии; чужая/нет → `404`.
- `POST /v1/sessions/revoke-others` — Bearer + `{ refresh_token }`: оставить сессию
из refresh, отозвать остальные; чужой subject → `403`; невалидный refresh → `401`.
- `GET /v1/user/me` — профиль пользователя.
- `PATCH /v1/user/me` — частичное обновление своего профиля: `language` (`ru`|`en`),
`nickname`, `timezone`, `phone`, `avatar_url`, `preferences`; смена пароля —
@@ -520,13 +622,20 @@ src/
- `GET /v1/user/reviews` — отзывы пользователя.
- `GET /v1/user/following` — календари, которые пользователь отслеживает (follow).
- `GET /v1/search` — поиск; **без токена** (гость) — только commercial по `can_access`;
пустой запрос + пагинация/`type` — discovery tops. С Bearer — как раньше (включая свои personal).
пустой запрос + пагинация/`type` — discovery tops. Personal **никогда** не в выдаче
(в т.ч. свои). С Bearer — commercial + доступные; busy для Time Arc берётся из personal отдельно.
В calendar-результатах — `image_url` (если задан на календаре). Upload/multipart
**не** входит в контракт: поле URL.
- `GET /v1/calendars` — список календарей (auth).
Гео: `lat`+`lon` (и опциональный `radius` 1..100) — ближайшие события и календари;
`sort=distance`; в элементах `distance_km` ([GEO.md](GEO.md)).
- `GET /v1/geo/suggest` — typeahead адреса (OpenCage); **без токена** + IP rate-limit.
- `POST /v1/geo/geocode``{q, lang?}``{address, lat, lon, source}` (Bearer).
- `POST /v1/geo/reverse``{lat, lon, lang?}` (Bearer). OpenCage недоступен/квота → `503` `geo_unavailable`.
- `GET /v1/calendars` — список календарей (auth): owned shared (`role`, `share_rights`,
`mirror_to_default`).
- `POST /v1/calendars` — создать календарь (`commercial` → нужна уже active sub/trial;
иначе `402`; auto-start trial нет).
- `GET /v1/calendars/:id` — календарь. **Без токена** для active commercial (`following: false`);
- `GET /v1/calendars/:id` — календарь **или unique `short_name`**. **Без токена** для active commercial (`following: false`);
personal без доступа → `403`. С сессией: `following`, `booking_open`.
- `PUT /v1/calendars/:id` — обновить календарь (`personal→commercial` → нужна уже
active sub/trial, иначе `402`).
@@ -542,6 +651,12 @@ src/
- `GET /v1/user/specialist-invites` — входящие приглашения.
- `POST /v1/specialist-invites/:id/accept` | `…/decline` — ответ invitee.
- `POST /v1/specialist-invites/accept` `{ token }` — accept по email deep-link.
- `GET/POST /v1/calendars/:id/share-invites` — исходящие share invite / создать (owner|admin).
- `DELETE /v1/calendars/:id/share-invites/:invite_id` — отменить pending.
- `GET /v1/calendars/:id/shares` — принятые grants; `DELETE/PUT …/shares/:user_id`.
- `PUT /v1/user/shares/:calendar_id` — свой `mirror_to_default`.
- `GET /v1/user/share-invites` — входящие share invites.
- `POST /v1/share-invites/:id/accept` | `…/decline`; `POST /v1/share-invites/accept` `{token}`.
- `GET /v1/calendars/:calendar_id/events` — события календаря (гость: commercial).
В JSON каждого события commercial-календаря — опциональное поле
`booking_occupancy`: `"free"` | `"pending"` | `"confirmed"`.
@@ -565,6 +680,10 @@ src/
- `POST /v1/events/:id/bookings` — запись на событие. Recurring master: тело
`{ "occurrence_start": "<ISO8601>" }` обязательно; booking на материализованный instance.
- `GET /v1/events/:id/bookings` — список бронирований события (владелец).
- `POST /v1/events/:id/waitlist` — лист ожидания (commercial + `settings.waitlist_enabled`);
см. §2.1.2 «Лист ожидания».
- `DELETE /v1/events/:id/waitlist` — выйти из очереди.
- `GET /v1/events/:id/waitlist` — статус (гость) или список (owner/specialist).
- `GET /v1/bookings/:id` — статус бронирования.
- `PUT /v1/bookings/:id` — подтвердить/отклонить (`confirm`|`decline`); владелец —
любые booking календаря; active specialist — только события со своим `specialist_id`.
@@ -586,7 +705,8 @@ src/
- `GET /v1/subscription` — подписка пользователя.
- `POST /v1/subscription``action=start_trial` | `activate` (+ `plan`, опционально
`payment_info`). Trial — только через `start_trial`, не через create commercial.
- `GET /v1/calendars/:calendar_id/view?month=YYYY-MM` — HTML-календарь (владелец), включая архив.
- `GET /v1/calendars/:calendar_id/events?from&to` — события диапазона: **hot** для живого окна;
archived month — из `month_snapshot` / файла, тот же JSON ([ARCHIVE.md](ARCHIVE.md), Back#76).
### WebSocket (порт 8081)
- `WS /ws` — пользовательский канал real-time (подписка на обновления событий календаря; детали — §2.11).
@@ -596,6 +716,7 @@ src/
- `GET /v1/admin/stats` — агрегированная статистика дашборда.
- `POST /v1/admin/login` — вход администратора.
- `POST /v1/admin/refresh` — обновление пары access/refresh JWT администратора.
- `POST /v1/admin/logout``{ refresh_token }` → отзыв admin `auth_session` (как user logout).
- `GET /v1/admin/users`, `GET /v1/admin/users/:id` — пользователи.
- `GET /v1/admin/users/:id/verification-token` — get/create email-verify token (стенды).
- `GET /v1/admin/users/:id/password-reset-token` — get/create password-reset token (стенды).
@@ -634,43 +755,47 @@ src/
| `current_jti` | Актуальный jti refresh JWT |
| `expires_at` | Срок жизни сессии (30 дней) |
| `revoked` | Флаг отзыва |
| `device_name` | Человекочитаемая метка устройства (с login; может быть пустой) |
| `user_agent` | `User-Agent` с login (может быть пустой) |
| `created_at` / `updated_at` | Создание / последняя ротация или отзыв |
**Refresh JWT (admin)** — claims:
- `typ=refresh`, `aud=admin`, `sub=<admin_id>`
- `sid=<session_id>`, `fid=<family_id>`, `jti=<current_jti>`
- `client=admin`, `exp`, `iat`
**Refresh JWT (user, фаза 2)** — claims:
**Refresh JWT (user)** — claims:
- `typ=refresh`, `aud=user`, `sub=<user_id>`
- `sid`, `fid`, `jti` — как у admin
- `client=web` (фаза 3: `mobile`), `exp`, `iat`
- `client=web` \| `mobile` (из сессии; refresh не меняет device/UA)
- Подпись: `JWT_SECRET` (user JWK), отдельно от admin
**Поток login admin** (`POST /v1/admin/login`):
1. Проверка email/password.
2. Создание записи `auth_session`.
3. Ответ: `{ token, refresh_token, user }`.
3. Ответ: `{ token, refresh_token, session_id, user }`.
**Поток refresh admin** (`POST /v1/admin/refresh`):
1. Верификация подписи и срока refresh JWT.
2. Сверка `jti` из JWT с `current_jti` в Mnesia.
3. При совпадении — ротация: новый `jti`, новая пара токенов.
3. При совпадении — ротация: новый `jti`, новая пара токенов + `session_id`.
4. При несовпадении (reuse) — `revoke_family`, ответ 401.
**Поток login user** (`POST /v1/login`):
1. Проверка email/password.
2. Создание записи `auth_session` (`subject_type=user`, `client_type=web`).
3. Ответ: `{ token, refresh_token, user }`.
2. Создание `auth_session` (`subject_type=user`, `client_type`, `device_name`, `user_agent`).
3. Ответ: `{ token, refresh_token, session_id, user }`.
**Поток refresh user** (`POST /v1/refresh`):
1. Верификация refresh JWT (`aud=user`).
2. Сверка `jti` с `current_jti` в Mnesia, ротация при успехе.
2. Сверка `jti` с `current_jti` в Mnesia, ротация при успехе; `client` берётся из сессии.
3. Reuse — `revoke_family`, 401.
**Фазы внедрения:**
- Фаза 1 (#23): admin API — реализовано.
- Фаза 2 (#26): user login + `/v1/refresh` — реализовано.
- Фаза 3: client web + mobile (явный `client_type`, тот же контракт).
- Фаза 3 (Back#74): явный `client_type` web|mobile, device meta, list/revoke sessions — реализовано.
Kick-on-login / concurrent policy по `client_type` — не сделано.
### Legacy (не используется login/refresh user)
- Таблица `session` (`ram_copies`) и opaque refresh в `core_session` — оставлены в кодовой базе, hot path user API переведён на `auth_session`.
@@ -698,10 +823,15 @@ src/
- Система не поддерживает транзакционную целостность между несколькими таблицами на уровне
приложения (полагаемся на Mnesia).
- В текущей версии отсутствует полноценная система уведомлений (только таблица).
- Загрузка файлов (вложения) пока не реализована.
- Серверный рендеринг календаря работает только для владельца календаря.
- Автоматическое архивирование через `archive_controller` в локальном режиме использует
`slave:start`, который устарел; в production планируется `peer`.
- Загрузка файлов: **avatar/cover**`POST /v1/user/me/avatar`,
`POST /v1/calendars/:id/cover` (multipart field `file`); MIME jpeg/png/webp
(magic bytes), лимит `UPLOAD_MAX_BYTES` (default 2MiB) → 413/415;
хранение local disk `UPLOAD_DIR` (default `/app/data/uploads` на volume
`eventhub-data`); отдача `GET /v1/media/:kind/:owner/:file` (public).
Вложения к событию — пока не реализованы.
- HTML `GET …/view` **удалён**; архив читается JSON-ом (Spec#20 / [ARCHIVE.md](ARCHIVE.md)).
- `archive_controller` + extra-node (`slave`/`peer`) **удалены**; архив — `month_snapshot`
на тех же нодах (Back#76, [ARCHIVE.md](ARCHIVE.md)).
## 9. ТРЕБОВАНИЯ К ОКРУЖЕНИЮ
- Erlang/OTP 28
+91 -21
View File
@@ -21,15 +21,19 @@ Deep-link на календарь, владельцем которого явл
Эндпоинты:
- `POST /v1/register` — регистрация (`email`, `password`); статус пользователя `pending` до верификации.
- `POST /v1/verify` — подтверждение email `{ token }`; после успеха у пользователя есть дефолтный personal-календарь.
- `POST /v1/login` — вход; ответ `{ token, refresh_token, user }`. Сессия `auth_session` с `client_type=web`.
- `POST /v1/refresh` — ротация пары токенов по `{ refresh_token }`.
- `POST /v1/login` — вход; тело: `email`, `password`, `client_type=web`, `device_name`
(метка браузера/ОС). Ответ `{ token, refresh_token, session_id, user }`.
- `POST /v1/refresh` — ротация по `{ refresh_token }`; ответ включает `session_id`.
- `POST /v1/logout``{ refresh_token }` до очистки storage.
- `GET /v1/sessions`, `DELETE /v1/sessions/:id`, `POST /v1/sessions/revoke-others`
активные сессии в профиле (устройство, «текущая», отзыв / выйти на других).
- `GET /v1/user/me` — профиль текущего пользователя.
Реализация:
- JWT в `localStorage` под ключами, отличными от Admin UI (избежать коллизии при общем origin).
- JWT + `session_id` в `localStorage` (ключи, отличные от Admin UI).
- Axios: `Authorization: Bearer <access>`; при 401 — single-flight refresh; при неудаче — редирект на `/login`.
- При старте приложения — `GET /v1/user/me` при наличии токена.
- Logout — только клиентский (очистка storage); серверного revoke нет.
- Logout — `POST /v1/logout`, затем clear storage.
- WebSocket: `ws://…:8081/ws?token=<access_jwt>`; subscribe/unsubscribe по `calendar_id`.
## 3. Технологический стек
@@ -66,7 +70,8 @@ Deep-link на календарь, владельцем которого явл
клике на вкладку; при смене mood — `DEFAULT_LENS_BY_MOOD`. Workspace chrome: (1) селектор;
(2) период + вид Месяц/Неделя/День; (3) owner actions — для commercial только
«+ Новое событие» («Заполнить расписание» — во вкладке Студия). Без flip / CREATOR.
Нет lens на HTML-архиве месяца / не-calendar routes.
Нет lens на не-calendar routes. Прошлый месяц — React-grid (не HTML-iframe); lens overview
на истории — минимум ([ARCHIVE.md](ARCHIVE.md), Front#73).
- **Week view (Front#40):** одна строка day-headers (без дубля заголовков
`WeekDayColumn`); горизонтальный скролл через `.eh-cal-week-scroll` (mobile ~390).
- Контекст виджета: свой календарь (селектор) или browse чужого (после поиска). Чужой `personal`
@@ -151,11 +156,12 @@ CONTROL→bookings. Контент: free gaps / события сегодня /
### 3.3. Time Arc (живая сетка, Front#50)
На commercial week/day в режиме Студия клиент считает предложенный час без LLM и без Back: свободные слоты студии ∩ занятость пользователя (personal + confirmed bookings) × mood (FLOW — воздух и середина дня, MOMENTUM — ближайший, CONTROL — доля свободных мест). Одна ячейка — `data-time-arc`; коллизии — `data-collided`. Тап — обычный Book.
На commercial week/day в режиме Студия клиент считает предложенный час без LLM и без Back: свободные слоты студии ∩ занятость пользователя (personal + confirmed bookings + **mirror** shared-personal) × mood (FLOW — воздух и середина дня, MOMENTUM — ближайший, CONTROL — доля свободных мест). Одна ячейка — `data-time-arc`; коллизии — `data-collided`. Тап — обычный Book.
Владелец: черновики слотов из прошлой ISO-недели в дырах текущей; «Создать неделю» —
Владелец **или** share `write|admin` (заместитель): черновики слотов из прошлой ISO-недели в дырах текущей; «Создать неделю» —
preview, затем существующий POST events. После создания черновик снимается сразу
(без F5). Без подтверждения в API не пишем. EventHubAiRouter не используется.
(без F5), в том числе если время в форме сдвинули (прошлый час → +1 день / правка start).
Фраза с часом уже в прошлом («11» в 14:00) ставит черновик на следующий день, тот же час.
**v2 (Front#51):** отказ от дуги (тап другой свободной ячейки / свайп недели) пишет штраф часу и мастеру в `localStorage` (`eh.timeArc.skips`) — дуга переезжает. ≥2 confirmed записей к одному `specialist_id` бустят его слоты. Месяц: тепло `data-time-arc-heat` на днях со скоренным часом. Personal: черновик «тот же час +7д» после прошедшего confirmed визита; тап открывает студию.
@@ -168,12 +174,22 @@ preview, затем существующий POST events. После созда
**v4 (Front#53):** pending occupancy тянет demand-черновики на тот же час в свободные дни текущей ISO-недели (`data-time-arc-demand`). Long-press пустого часа week/day у владельца commercial — локальный разбор фразы → phrase-черновик (`data-time-arc-phrase`); клик и long-press месяца по-прежнему открывают create. Специалист на чужой студии: дуга на своём будущем pending-слоте. Без LLM / AiRouter.
**Share / заместитель (Back#73, согласование Front#49):** `GET /v1/calendars` включает shared;
busy Time Arc += события personal с `mirror_to_default`; ghost/demand/phrase у share
`write|admin`; Following не смешивать с share. Confirmed foreign bookings overlay на
default personal — только `role=owner` ids в ownSet.
Канон ИИ клиента (подсказки / голос, Future — [PRODUCT-AI.md](PRODUCT-AI.md), см. §7.3):
умность — в ячейках; опциональные hint-ghosts и голос-кнопка mark → ActionPlan →
подтверждение. Не чат; AiRouter вне продукта.
## 4. Маршруты
Публичные:
- `/login`, `/register`, `/verify`, `/forgot-password`, `/reset-password`
- `/search`, `/discover` — каталог без сессии
- `/c/:calendarId`, `/c/:calendarId/e/:eventId` — read-only неделя commercial-студии;
`calendarId` — UUID **или** `short_name` (гость: active commercial).
запись / follow / «мой календарь» → `/login?next=`
Защищённые (`ProtectedRoute`):
@@ -200,13 +216,26 @@ Redirects: `/discover` → `/search`; `/calendars/:id` → `/c/:id`; `/calendars
Регистрация, верификация, вход, восстановление сессии.
### 5.2. Профиль
`GET /v1/user/me` + форма на `/profile`. Редактируемые через `PATCH /v1/user/me`: `nickname`, `phone`, `timezone`, `avatar_url`, `language`; смена пароля — `current_password` + `password`. Read-only в UI: `id`, `email`, `role`, `status`. Mood — chip в AppShell рядом с BrandWordmark (+ compact на auth); persist `preferences.mood` через `PATCH /v1/user/me` (**не** в `/more`, **не** в calendar toolbar). Язык при первом визите — из браузера; при первом логине без `language` — пишется в профиль. Mood по умолчанию `calm`, при логине не сидится. Lens — только localStorage (не профиль).
`GET /v1/user/me` + форма на `/profile`. Редактируемые через `PATCH /v1/user/me`: `nickname`, `phone`, `timezone`, `avatar_url`, `language`; смена пароля — `current_password` + `password`. Upload аватара: `POST /v1/user/me/avatar` (multipart `file`) → обновляет `avatar_url` (Front#69). Read-only в UI: `id`, `email`, `role`, `status`. Mood — chip в AppShell рядом с BrandWordmark (+ compact на auth); persist `preferences.mood` через `PATCH /v1/user/me` (**не** в `/more`, **не** в calendar toolbar). Язык при первом визите — из браузера; при первом логине без `language` — пишется в профиль. Mood по умолчанию `calm`, при логине не сидится. Lens — только localStorage (не профиль).
### 5.3. Поиск (участник)
`GET /v1/search` — фильтры (`type`, `q`, `tags`, даты, `lat`/`lon`/`radius`, sort, order, пагинация). Без `q`/дат/tags/geo/sort — топы календарей и событий; иначе — поиск. В списке бейдж типа — `calendar`|`event` (не `personal`/`commercial`). Даты, теги, geo и сортировка свёрнуты по умолчанию. Переход к `/c/:id` или `/c/:id/e/:eventId`.
В результатах calendar (и event, если Back отдаёт) может быть `image_url` — Discover показывает
cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-discover-media--fallback`,
не fake photo-hero). Битый `image_url` → fallback. **Upload API нет** — поле URL (seed / ручная установка).
не fake photo-hero). Битый `image_url` → fallback. Cover upload владельца: `POST /v1/calendars/:id/cover` (Front#69); URL также можно задать через `PUT` `image_url`.
**Гео (волна 2, [GEO.md](GEO.md), Front#74):** основной путь — «рядом со мной» (GPS) или
центр по адресу (suggest), не ручные lat/lon. Радиус опционален (пресеты 1/5/10/25/50 км
и «без ограничения»). В выдаче — `distance_km`. Список, не map-first.
**Вид карты (Back#77):** у результатов segment-переключатель «Список | Карта»
(`?view=map` в URL, переживает перезагрузку). Данные — тот же `GET /v1/search`
(`useSearch`); в режиме карты пагинация списка и блок «ближайшее» скрыты.
Маркеры группируются по точке (~1 м): пин календаря с бейджем числа событий в группе,
событие с отличной точкой — отдельная малая точка; клик по маркеру → попап со списком
карточек группы (те же href `/c/:id` и `/c/:cid/e/:eid`, что в списке). Результаты без
координат не рисуются — счётчик «Без адреса: N». При активном гео-фильтре — точка центра
и круг радиуса. Тайлы OpenFreeMap (MapLibre GL, без ключа); компонент карты — lazy-chunk.
### 5.4. Календари
- Список своих: `GET /v1/calendars`
@@ -215,7 +244,9 @@ cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-disco
(UI: «Отслеживать» в about, страница `/following`; не путать с `/subscription`)
- Просмотр коммерческого чужого календаря по id; при `booking_open=false` — баннер
«Запись временно недоступна», кнопка записи скрыта
- HTML month view владельца: `GET /v1/calendars/:calendar_id/view?month=YYYY-MM` — при переключении виджета на **прошедший месяц** (owner + вид «Месяц») автоматически подменяет клиентский grid серверным HTML (live/архив, задача #15). Текущий и будущие месяцы — React-виджет по `GET …/events`.
- Прошедший месяц/неделя/день: тот же React-виджет по `GET …/events?from&to` (сервер отдаёт
snapshot/файл); бейдж «Архив». HTML `GET …/view` **нет** ([ARCHIVE.md](ARCHIVE.md),
Front#73). Текущий и будущие месяцы — без изменения, только hot.
- Создание / апгрейд `commercial` без **уже active** sub/trial → `402``/subscription`
(trial — явный `start_trial`, не auto при create); после оплаты —
возврат к созданию/редактированию
@@ -296,16 +327,20 @@ cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-disco
### 5.7. Отзывы
`GET/POST /v1/reviews`, `GET/PUT/DELETE /v1/reviews/:id`, `GET /v1/user/reviews`. Цели: `event` | `calendar`.
Голос: `PUT /v1/reviews/:id/vote` `{ "value": "like"|"dislike" }`, `DELETE /v1/reviews/:id/vote`; в ответах — `my_vote`.
UI: форма отзыва скрыта без confirmed booking (event — booking на это событие; calendar — booking на событие календаря); подсказка + жалоба остаются.
UI: без сессии скрыты форма отзыва, жалоба (календарь/событие/отзыв) и кнопки like/dislike
(счётчики лайков в списке отзывов остаются). С сессией: форма отзыва скрыта без
confirmed booking; подсказка gated + жалоба остаются (жалоба не требует booking).
### 5.8. Жалобы
`POST /v1/reports``target_type`: `event` | `calendar` | `review`. Жалоба не требует booking.
В client UI жалоба доступна только залогиненному пользователю.
### 5.9. Подписка
`GET /v1/subscription`, `POST /v1/subscription` (`start_trial` | `activate` + `plan` + опционально `payment_info`).
Планы и цены (как Back `plan_price/1`, minor units → ₽): monthly **999**, quarterly **2499**, biannual **4499**, annual **7999**.
UI: карточки сравнения, локализованный «Бесплатно» без подписки, trial, демо-оплата (шлюз-заглушка).
Create/upgrade commercial без **уже active** sub/trial → `402``/subscription`
**Пилот (Spec#18 A):** без боевого PSP; commercial через `start_trial` и/или admin activate.
B2C оплаты услуги нет. Create/upgrade commercial без **уже active** sub/trial → `402``/subscription`
(сначала явный `start_trial` или `activate`; auto-start trial при create **нет**,
Back#61 / BackSpec §2.1.2, §2.9). После renew commercial-календари владельца снова
с `booking_open=true` без смены type (restricted → full).
@@ -356,18 +391,53 @@ CI: lint → build → Playwright **mock** (до push) → push image → deploy
E2E: `e2e/TESTIDS.md`. Моки (`npm run test:e2e`, `project=mock`) — только локально / до деплоя. После деплоя на IFT/stage — `SMOKE_USER_*` + `npm run test:e2e:ift|stage` против реального API (без Playwright route-моков).
## 7. Ограничения MVP (Future)
## 7. Ограничения MVP / Фаза 2 (Spec#8)
Не в текущей волне (кроме specialist_invite — см. §5.4 / BackSpec §2.1.2):
- шаринг календаря с правами (`calendar_share`) — фаза 2 (не путать со specialist_invite)
- серверный logout / revoke session
- загрузка файлов (вложения)
- явный `client_type=mobile` (фаза 3 бэка)
- полноценный push (сейчас in-app `notification` + email для specialist_invite)
- waitlist / оплата услуги клиентом (B2C)
Не в текущем контракте реализации (кроме specialist_invite — см. §5.4 / BackSpec §2.1.2).
Эпик: https://git.sabilin.com/EventHub/EventHubSpec/issues/8
**Pilot / ops (фаза 2):** SMTP / transactional email; backup; secrets / certs / alerts;
legal stubs; позиция по подписке **владельца**; DNS/SPF для `calentiq.com` (домен в использовании; чеклист `BRANDING.md`).
**Продукт (фаза 2):**
- серверный logout / revoke session (`POST /v1/logout` + `{ refresh_token }`; клиент зовёт до clear storage) — сделано
- список/отзыв сессий в профиле (Back#74 / Front#72): устройство (`device_name`/UA),
revoke одной и revoke-others
- загрузка файлов: avatar `POST /v1/user/me/avatar`, cover
`POST /v1/calendars/:id/cover` (Back#71); UI — Front#69; вложения — позже
- полноценный push / reminders (email-reminder: Back#70; Web Push + prefs:
Back#75 / Front#71 — тумблеры в профиле, SW `/sw-push.js`)
- waitlist: API Back#72; включение — чекбокс «Лист ожидания»
(`settings.waitlist_enabled`) в настройках commercial; join/leave на
карточке события при полном слоте — Front#70
- шаринг календаря с правами (`calendar_share`) — Back#73 API + Front list/invites/mirror;
не путать со specialist_invite / Following
- нативный `client_type=mobile` клиент — хвост (API уже принимает `mobile`)
**Вне горизонта (не фаза 2):** оплата услуги клиентом (B2C).
Лайки/дизлайки отзывов: `PUT/DELETE /v1/reviews/:id/vote`, поле `my_vote` в ответах отзывов (EventHubBack#47).
## 7.1. Фаза 3 — архив (Spec#19 / Spec#20)
Канон: [`ARCHIVE.md`](ARCHIVE.md). Hot = текущий месяц + будущее; warm = 3 мес;
cold = файлы. UI истории — React, не iframe. Код: Back#76, Front#73.
## 7.2. Фаза 3 — гео (Spec#19 / Spec#21)
Канон: [`GEO.md`](GEO.md). Стек: MapLibre + OpenFreeMap + OpenCage (внешний геокодер через бэк).
Пикер адреса/пина; карточка с мини-картой и outbound Google/Яндекс/OSM;
Discover ближайшие с опциональным радиусом. Код: Back#77, Front#74, DevOps#16.
## 7.3. Фаза 3 — ИИ клиента (Spec#19 / Spec#22)
Канон: [`PRODUCT-AI.md`](PRODUCT-AI.md). Позиция «ИИ молчит и делает»: L0 умность в
ячейках Time Arc (shipped, без LLM); L1 опциональные подсказки — ghosts `kind: 'hint'`
с причиной (Front#75); L2 голос-кнопка Time Arc mark, press-to-talk → ActionPlan-чипы
с подтверждением пишущих действий, палитра команд как fallback (Front#76). STT/intent
через Back-прокси `/v1/ai/*`, как GEO/OpenCage. Anti-scope: чат, Composer, AI-home,
авто-booking без подтверждения, подводка AiRouter к Front. Код — после гейтов канона.
## 8. Источники истины
- Маршруты и поведение: `EventHubBack/src/eventhub_app.erl` + handlers
+225
View File
@@ -0,0 +1,225 @@
# Гео и карты (Фаза 3, волна 2)
Канон геокодирования, хранения координат и UX карты. Трекер: [Spec#21](https://git.sabilin.com/EventHub/EventHubSpec/issues/21), эпик [Spec#19](https://git.sabilin.com/EventHub/EventHubSpec/issues/19).
Реализация: [Back#77](https://git.sabilin.com/EventHub/EventHubBack/issues/77), [Front#74](https://git.sabilin.com/EventHub/EventHubFront/issues/74), [DevOps#16](https://git.sabilin.com/EventHub/EventHubDevOps/issues/16). Этот документ — контракт; код подтягивается после него.
## Цель
Владелец задаёт место события (и org-default календаря) через поиск адреса и пин на карте. Координаты **хранятся у нас**. Участник ищет **ближайшие** commercial-календари и события с опциональным радиусом. Гость видит мини-карту и может открыть ту же точку в привычном приложении карт.
Критерий выигрыша: не зависеть от ToS Google/Яндекс на кэш геокода; не блокировать create события, если геокодер лежит.
## Почему не Google / Яндекс как провайдеры
Событие — общая сущность: владелец пишет `lat`/`lon`, участники и Discover читают те же числа.
- **Google Geocoding:** кэш lat/lng до 30 дней, затем удалить; бессрочно — `place_id`. Исключение «кэш изолирован на одного end-user» **не покрывает** общую точку события. Результат Google нельзя показывать на чужой карте.
- **Яндекс (бесплатный API):** данные геокодера нельзя сохранять в свою БД. Коммерческая лицензия — отдельный договор и обязательный логотип / «Открыть в Картах».
**Не используем:** Google Maps JavaScript API, Geocoding/Places, Яндекс JS API / Геокодер, ключи карт.
**Разрешено:** outbound-ссылки на Google Maps и Яндекс.Карты с **нашими** координатами (OpenCage или пин пользователя). Это не вызов их API и не хранение их ответа.
## Стек (вариант Ц)
| Слой | Выбор | Не берём |
|------|--------|----------|
| Карта в браузере | [MapLibre GL JS](https://maplibre.org/) | Leaflet + растр `tile.openstreetmap.org` (политика тайлов OSMF) |
| Тайлы | [OpenFreeMap](https://openfreemap.org/) публичный инстанс, без ключа | Carto/MapTiler Free (не для коммерции EventHub); свой tile-server |
| Геокод | **[OpenCage](https://opencagedata.com)** (внешний API), прокси только через EventHubBack, ключ не покидает бэкенд | публичный Nominatim OSMF (1 rps, autocomplete запрещён); `photon.komoot.io`; self-host Photon (вес индекса, RAM) |
| Хранение | `address` + `lat`/`lon` в EventHub | `place_id` Google, сырой ответ Яндекса |
ToS OpenCage разрешает бессрочно хранить результаты геокодирования — кэш и сохранённые координаты события легальны. Данные OSM — [ODbL](https://www.openstreetmap.org/copyright). На карте видимая атрибуция: «© OpenStreetMap contributors» со ссылкой на copyright.
```
Front (MapLibre) --tiles--> OpenFreeMap
Front --suggest/geocode/reverse--> Back /v1/geo --> OpenCage API (ключ в env бэка)
Back --persist--> event.location / calendar.settings.default_location
```
Front **не** ходит в OpenCage и Nominatim напрямую (ключ не должен попасть в браузер).
## Модель данных (уже есть)
- Событие: `location` = `{ address, lat?, lon? }`.
- Календарь: `settings.default_location` = `{ address, lat?, lon? }` (как сейчас: `address` непустой; `lat`/`lon` оба или ни одного).
Волна 2:
- **Address-only разрешён.** Create/update события с адресом без координат **не** отбрасывает `location` (сейчас `parse_location` без пары lat/lon даёт `undefined`).
- `lat`/`lon` — оба или ни одного; не пара `0, 0` как «пусто» в API (пусто = поля отсутствуют / `null`).
- Поиск «рядом»: см. § «Ближайшие календари и события». Сейчас geo режет только события; календари по `default_location` — волна 2 (Back#77).
## API геокодирования (контракт Back#77)
`POST` geocode/reverse: user Bearer (пикер). `GET /v1/geo/suggest`: **без токена** (Discover «центр по адресу» для гостя) + rate-limit по IP; с Bearer — тот же лимит на user. Ключ OpenCage с браузера не светим.
Кэш одинаковых `q`+`lang` (ETS/Mnesia, TTL дни). Таймаут апстрима ~3 s.
### `GET /v1/geo/suggest?q=&lang=`
Typeahead. Клиент debounce (≥300 ms). Пустой / слишком короткий `q``400` `{error: "invalid_query"}`.
Ответ `200`:
```json
{
"results": [
{ "address": "Москва, Красная площадь", "lat": 55.7539, "lon": 37.6208 }
]
}
```
Пустой список — валидный `200`, не ошибка.
### `POST /v1/geo/geocode`
Тело: `{ "q": "…", "lang": "ru" }` (`lang` опционален, иначе язык пользователя / `ru`).
- `200`: `{ "address": "…", "lat": 55.75, "lon": 37.62, "source": "opencage" }`
- нет хита: `404` `{error: "not_found"}`
- OpenCage недоступен / таймаут / квота (402/403/429): `503` `{error: "geo_unavailable"}`**не** 500
### `POST /v1/geo/reverse`
Тело: `{ "lat": 55.75, "lon": 37.62, "lang": "ru" }`. Подпись пина после drag.
Тот же shape, что geocode (`source: "opencage"`). Нет хита → `200` с `address` из округлённых координат или короткий fallback «lat, lon», без падения пикера. `503` только если OpenCage недоступен.
### Degrade
- Geo-эндпоинты: `503` / пустой suggest, не 500 на весь API.
- Create/update события и `default_location`: **не** зависят от геокодера. Текстовый адрес сохраняется.
- IFT/stage без ключа OpenCage: сервис живой; e2e пикера пропускают или проверяют degrade (текстовое поле).
## Ops (контракт DevOps#16)
Не API-ключи Google/Яндекс.
- Своего контейнера геокодера нет: бэкенд ходит во внешний OpenCage API (`https://api.opencagedata.com/geocode/v1/json`).
- Env бэкенда: `OPENCAGE_API_KEY` (секрет, только в `.env` стендов), `OPENCAGE_TIMEOUT_MS`, опционально `OPENCAGE_COUNTRYCODE`, `OPENCAGE_THROTTLE_RPS` (1 rps под free-тариф). Нет map API keys.
- Free-тариф OpenCage: 2500 запросов/сутки, 1 rps; 402/403/429 апстрима → `503 geo_unavailable`. ToS разрешает хранение результатов.
- CSP (Traefik / Front): тайлы OpenFreeMap, не Google/Yandex script.
Ориентир CSP (уточнить по фактическому style URL):
```
connect-src 'self' https://tiles.openfreemap.org
img-src 'self' data: blob: https://tiles.openfreemap.org
font-src 'self' data: https://fonts.gstatic.com https://tiles.openfreemap.org
worker-src 'self' blob:
```
Geo — same-origin `/v1/geo`. IFT/stage: ключ OpenCage в `.env` стенда; без ключа — degrade, не падение EventHub.
**Stage:** ключ OpenCage в `.env` (внешний сервис — без нагрузки на VPS). Пикер адреса работает на всех стендах с ключом; без ключа — текстовый адрес / пин. GPS «рядом» работает без геокодера.
## Front (контракт Front#74)
Библиотека: MapLibre GL JS. Стиль OpenFreeMap (например Liberty). Ключей в репозитории Front нет.
### Пикер
Форма события и org `default_location` (не сырые lat/lon как основной путь):
1. Поле адреса + suggest (`GET /v1/geo/suggest`).
2. Выбор хита → подставить address/lat/lon.
3. Мини-карта, перетаскиваемый пин → `POST /v1/geo/reverse`.
4. Ручной lat/lon можно спрятать (advanced), не убирать из контракта API.
OpenCage `503`: тост/подсказка, карта и ручной адрес остаются.
### Карточка / просмотр
Если есть валидные `lat`/`lon`:
- мини-карта MapLibre с пином и атрибуцией OSM;
- **рядом** ссылки (`target=_blank`, `rel="noopener noreferrer"`):
- Google Maps: `https://www.google.com/maps?q={lat},{lon}`
- Яндекс.Карты: `https://yandex.ru/maps/?pt={lon},{lat}&z=16` (порядок **lon,lat**)
- OpenStreetMap: `https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}`
Только адрес без координат: текст адреса, без карты и без deep-link.
i18n ru/en.
### Discover — ближайшие
Не map-first: тот же список `/search`. Ручной ввод lat/lon **не** основной путь.
**Центр поиска** (нужны оба `lat` и `lon`):
1. «Рядом со мной» — `navigator.geolocation` (гость и логин). Отказ/ошибка — без падения; подсказка выбрать адрес.
2. «Искать у адреса» — suggest (`GET /v1/geo/suggest`) → lat/lon центра. Не GPS.
**Радиус опционален** (км, UI-пресеты **1 / 5 / 10 / 25 / 50**):
| Радиус | Query | Смысл |
|--------|--------|--------|
| не выбран / «без ограничения» | `lat`,`lon` без `radius` | все точки с координатами, **сортировка по dist** |
| выбран пресет | `lat`,`lon`,`radius` | только ≤ N км, сортировка по dist |
Дефолт после «рядом со мной»: **10 км** (как сейчас на бэке), пользователь может сменить или снять лимит. `radius` без пары lat/lon игнорируется.
Фильтр `type` (`calendar` / `event` / оба) работает как сейчас. `q`/даты/теги сочетаются с geo.
В карточке результата — дистанция (`distance_km` с бэка), не сырые координаты.
## Ближайшие календари и события (контракт Back#77)
Тот же `GET /v1/search`. Geo-режим: есть `lat` и `lon`**не** discovery-tops.
### Что сравниваем
| Тип | Точка | Нет координат |
|-----|--------|----------------|
| `event` | `event.location.lat/lon` | вне geo-выборки |
| `calendar` | `settings.default_location.lat/lon` | вне geo-выборки (сейчас календари geo **не** фильтруются — это дыра волны 2) |
Personal по-прежнему не в search. ACL `can_access` без изменений. Restricted commercial — как в обычном search.
Формула: haversine, км (уже есть `logic_search:distance/4`).
### `radius`
- Задан: integer **1..100** км; вне диапазона → `400`. Фильтр `distance ≤ radius`.
- Не задан: **без отсечения** по dist; только сущности с валидными координатами.
- Default бэка `radius=10`, если клиент прислал lat/lon **без** ключа radius — **менять:** отсутствие ключа = без лимита. Front при «рядом» **явно** шлёт `radius=10` (или выбранный пресет).
### Сортировка
Geo-режим, клиент не передал `sort`**`distance` asc**. Явный `sort=title|created_at|start_time` — как сейчас, но только внутри уже отфильтрованных по радиусу.
Новое значение: `sort=distance` (км по возрастанию; `order=desc` — дальние первыми, редко нужно).
### Ответ
В каждом элементе `events[]` / `calendars[]` при geo-запросе:
```json
"distance_km": 1.4
```
Округление 1 знак. Без geo-параметров поле **нет** (не `null`).
Календари в geo-выдаче могут отдавать краткий `default_location` (`address`, `lat`, `lon`) — чтобы Front не гадал.
Индекс/PostGIS — **не** волна 2: тот же in-memory scan, что сейчас.
## Non-goals волны 2
- Turn-by-turn, пробки, трекинг курьеров/сотрудников.
- Map-first Discover / каталог пинами на всю выдачу.
- Свой tile-server / self-host OpenFreeMap.
- Публичный Nominatim или `photon.komoot.io` как fallback (чтобы не сесть на чужой fair-use).
- Google/Yandex JS SDK, ключи, Places Autocomplete.
- Пакетная геокодировка существующих событий без координат (можно later).
## Критерии приёмки канона
- [x] Этот документ согласован (Spec#21, вариант Ц).
- [ ] DevOps#16: ключ OpenCage в `.env` стендов, CSP OpenFreeMap; без Google/Yandex keys.
- [ ] Back#77: `/v1/geo/*`, кэш, address-only location; search: geo по календарям, optional radius, `sort=distance`, `distance_km`; тесты (mock OpenCage).
- [ ] Front#74: пикер + мини-карта + outbound Google/Яндекс/OSM; Discover ближайшие (GPS/адрес, пресеты радиуса); e2e/mock.
- [ ] После тестов: BackSpec/FrontSpec сверены с кодом.
+183
View File
@@ -0,0 +1,183 @@
# ИИ клиента CalenTIQ (Фаза 3, волна 3)
Канон продуктового ИИ: умная сетка + опциональные предиктивные подсказки + голосовой центр выполнения **без чата**. Трекер: [Spec#22](https://git.sabilin.com/EventHub/EventHubSpec/issues/22), эпик [Spec#19](https://git.sabilin.com/EventHub/EventHubSpec/issues/19).
Реализация (Future): [Front#75](https://git.sabilin.com/EventHub/EventHubFront/issues/75) подсказки, [Front#76](https://git.sabilin.com/EventHub/EventHubFront/issues/76) голосовой центр. Этот документ — контракт; код подтягивается после него.
## Позиция: «ИИ молчит и делает»
ИИ CalenTIQ — не собеседник и не интерфейс. Это слой, который **предлагает действия**, а человек их подтверждает и исполняет. Никакого свободного текста от ИИ, никакого диалога, никаких уточняющих вопросов, никакой «личности ассистента».
Три следствия:
1. Результат любой ИИ-операции — **план действий в UI** (чипы, ghosts, навигация), а не текст.
2. Неопределённость разрешается **выбором из кандидатов**, не разговором.
3. Запись в API — только после явного подтверждения человека.
## Уровни ИИ
| Уровень | Что | Статус | Где живёт |
|---------|-----|--------|-----------|
| **L0 — умность в ячейках** | детерминированный скоринг Time Arc (без LLM, без Back) | shipped (v1v4) | ячейки сетки |
| **L1 — предиктивные подсказки** | те же ghosts, `kind: 'hint'` + причина «почему» | Future (Front#75), opt-in | ячейки сетки |
| **L2 — голосовой центр** | голос → ActionPlan → подтверждение | Future (Front#76), opt-in | кнопка Time Arc mark |
Единый визуальный язык: подсказка — **не** тост и **не** баннер, а ghost прямо в сетке. Интеллект всегда в ячейках — подсказки тоже в ячейках. Новый UI-слой не изобретается: расширяется уже shipped-механика ghosts (`copy` / `demand` / `phrase` / `repeat``+hint`).
## Статус Time Arc v1v4 (L0, shipped)
См. FrontSpec §3.3. Кратко:
- **v1 (Front#50):** клиентский скоринг лучшего часа: свободные слоты студии ∩ занятость пользователя × mood. Черновики владельца из прошлой недели. Без LLM и без Back.
- **v2 (Front#51):** память отказов `eh.timeArc.skips` (штраф часу/мастеру, дуга переезжает); буст мастера с ≥2 confirmed; тепло месяца.
- **v3:** дуга между календарями — `/following` и `/search` баннеры ближайшего часа.
- **v4 (Front#53):** demand-черновики по pending occupancy; локальный разбор фразы → phrase-черновик у владельца (long-press). Без LLM / AiRouter.
Всё L0 — локальные вычисления Front. Этот канон L0 **не меняет**.
## Голос-кнопка (L2, контракт Front#76)
### Кнопка
- Кнопка = **Time Arc mark** рядом с BrandWordmark в AppShell (см. FrontSpec §3.2) — всегда видна, отдельного слота не занимает.
- **Press-to-talk**: нажал и держишь → говоришь → отпустил → разбор. Отпустил без звука — ничего не происходит. Никакого «зависшего» прослушивания, таймеров VAD и модалок.
- Состояния (4): `idle` / `listening` / `processing` / `done` — анимируются **самим mark** (дуга заполняется, пока держишь). Бренд сам себе индикатор.
### Палитра быстрых команд (обязательный fallback)
Голос и тап — **два канала одного движка действий**. Кнопка при недоступности голоса (offline, mic denied, STT упал) открывает палитру тапабельных типовых действий из того же enum интентов. UX голоса не умирает ни в одном degrade-сценарии.
### Результат — план действий
После разбора показывается стек action-чипов (план), а не текст. Пишущие действия — с одной кнопкой «Выполнить»; обратимые исполняются сразу (см. классы ниже).
## ActionPlan (контракт later)
STT + intent-разбор выдают структурированный план, не текст. Намётка схемы:
```json
{
"version": 1,
"locale": "ru",
"confidence": 0.87,
"actions": [
{ "id": "a1", "intent": "open_calendar", "target": { "calendar_hint": "аурора" }, "resolved": null },
{ "id": "a2", "intent": "select_hour", "time": { "hour": 19, "day": "next", "dow": 2 } },
{ "id": "a3", "intent": "book", "confirm": "required" }
],
"candidates": []
}
```
### Интенты — конечный enum
Каждый интент исполняется через **уже существующий** UI-флоу. Новых write-эндпоинтов в Back нет.
| Intent | Глаголы (ru / en) | Исполняется через |
|--------|-------------------|-------------------|
| `navigate` | перейти, открой, покажи / go, open | роуты FrontSpec §4 |
| `open_calendar` | календарь + название | `/c/:id`; fuzzy-match по `GET /v1/calendars` + following |
| `select_hour` | «в 19», «завтра в 11» | курсор `calendarContextStore` + подсветка ячейки |
| `book` | запиши, записаться / book | существующий флоу Book (mandatory confirm + owner pending) |
| `create_slot_draft` | создай слот, поставь окно | механика phrase-черновиков v4: голос — ещё один источник фразы |
| `confirm_booking` / `decline_booking` | подтверди, отклони заявку | `PUT /v1/bookings/:id` через `/bookings` |
| `follow` / `unfollow` | подпишись, отслеживай | `POST/DELETE /v1/calendars/:id/follow` |
| `search` | найди, ищи | `/search` с заполненными фильтрами |
Всё вне enum — отбрасывается одним тостом, без retry-цикла.
### Разбор: rule-first
Двухслойный: словарь глаголов + regex покрывает типовые фразы («запиши к Ане завтра в 19» — глагол + имя + время, детерминированно). LLM-провайдер вызывается **только** при miss и обязан вернуть тот же ActionPlan-JSON — никогда свободный текст.
`create_slot_draft` у владельца использует локальный разбор фразы из v4 → **полностью офлайн** rule-path.
### Resolution и кандидаты (вместо вопросов)
Каждый `target.hint` резолвится **на клиенте** по локальным данным (календари, following, bookings, roster). Не резолвится → пишется в `candidates` и показывается чипами на выбор. Уточняющих вопросов нет по контракту.
### Классы действий
| Класс | Примеры | Подтверждение |
|-------|---------|----------------|
| **Обратимые** | `navigate`, `open_calendar`, `select_hour`, `search` | исполняются сразу — навигацию всегда можно отменить «назад» |
| **Пишущие** | `book`, `create_slot_draft`, `confirm/decline_booking`, `follow` | обязательный preview + явный тап «Выполнить» |
Обязательное подтверждение перед записью в API соблюдается буквально: запись в API — это только пишущие действия.
### Dry-run preview
Пишущие действия рендерятся в сетку **до** «Выполнить»: `create_slot_draft` — ghost-ячейкой, `book` — подсветкой целевого часа. Превью плана — тем же визуальным языком, что результат: пользователь видит ровно то, что получит.
### Исполнение
Последовательное, стоп на первой ошибке, без авто-продолжения. `book` всегда последний в плане и всегда с confirm; сверху — существующий owner-confirm pending (двойная защита записи).
## Предиктивные подсказки (L1, контракт Front#75)
- Подсказка = ghost `kind: 'hint'` в сетке + причина «почему».
- **Причины — шаблоны i18n, не LLM**: «вы обычно по вторникам в 19:00». Объяснимость без провайдера.
- Сигналы — **local-first**: паттерны bookings, `eh.timeArc.skips`, mood; ничего не уходит провайдерам.
- Частотный cap: не более N hint в неделю на экран (N уточняет Front#75).
- Dismiss пишет штраф в `timeArcMemoryStore` — тот же механизм skips; подсказка обучается отказам локально.
## Opt-in, privacy, degrade
### Opt-in
- L1 и L2 — **default off**. Тумблеры в `preferences` (`PATCH /v1/user/me`), как mood; до логина — выключены безусловно.
- Выключенный L1: ghosts v1–v4 работают как сейчас, `hint` не рисуется. Выключенный L2: mark не реагирует на hold, палитра скрыта.
### Приват-инварианты
- Транскрипт живёт **только в памяти** на время показа плана; не пишется ни в localStorage, ни в Back.
- Контекст календаря (события, bookings, предпочтения) **никогда** не уходит провайдерам — только аудио/фраза запроса.
- Аудио не сохраняется: стриминг в STT, discard после транскрипта.
### Degrade-матрица
| Сценарий | L1 подсказки | L2 голос |
|----------|--------------|----------|
| Offline | работают (local-first) | inert-state + тултип; у owner доступен rule-path `create_slot_draft` |
| Mic denied / нет микрофона | n/a | открывается палитра быстрых команд |
| STT недоступен / 5xx / таймаут | n/a | один тост; палитра как fallback |
| Низкий confidence | n/a | один тост «не расслышал», без retry-цикла |
| Средний confidence | n/a | чипы-кандидаты вместо прямого исполнения |
| Target не найден | подсказка не показывается | кандидаты; пустой список → тост |
| Write-ошибка при исполнении (409/403/402) | n/a | план остановлен; стандартная ошибка флоу (402 → `/subscription`) |
| Opt-in off | ghosts v1v4 без изменений | mark без реакции |
**Инвариант:** ни одно состояние ИИ-слоя не блокирует первичные флоу (сетка, запись, навигация живут независимо).
## Провайдер и Back-прокси (контракт later)
- STT и intent-разбор — **продуктовый провайдер** (вендор STT + мелкий LLM для intent-JSON при miss rule-слоя).
- Front ходит только в прокси Back (`/v1/ai/*`): ключи провайдера не покидают бэкенд, единая точка rate-limit, аудита и смены провайдера. Паттерн идентичен GEO/OpenCage (см. [GEO.md](GEO.md)).
- Back не персистит аудио и транскрипты.
- **EventHubAiRouter — не продуктовый путь**: это dev-tooling для coding-агентов (Zed). Продуктовый ИИ-клиент о нём не знает; зависимостей Front → AiRouter нет и не будет.
## Anti-scope
Вне продукта (зафиксировано):
- чат с ИИ в любой форме; Composer; отдельная AI-вкладка / AI-home;
- авто-booking и любые записи в API без подтверждения человека;
- свободные текстовые ответы ИИ и уточняющие вопросы;
- подводка EventHubAiRouter / Zed к клиентскому приложению;
- персист аудио/транскриптов, отправка контекста календаря провайдерам.
## Критерии старта Future-реализаций
Метрики для решения (собираются в ходе L0/Front#75):
- hint CTR; plan-confirm-rate; plan-abort-rate; доля rule-path vs provider-path.
Гейты:
- **Подсказки (Front#75):** ≥20% активных пользователей с ≥2 confirmed bookings через ghosts за месяц + явное продуктовое решение.
- **Голос (Front#76):** после пилота подсказок; STT-провайдер с RU и приемлемым ToS; пилот на IFT; минимум один сценарий с конверсией лучше ручного пути.
## Future Stories
- [Front#75](https://git.sabilin.com/EventHub/EventHubFront/issues/75) — предиктивные подсказки (ghosts `kind: 'hint'`).
- [Front#76](https://git.sabilin.com/EventHub/EventHubFront/issues/76) — голосовой центр: mark press-to-talk + палитра + ActionPlan.
+4
View File
@@ -5,6 +5,10 @@
## 4. [Правила работы (workflow)](WORKFLOW.md)
## 5. [Stage UI в Cursor Browser](STAGE-BROWSER.md) (HTTPS `*.calentiq.com`; proxy — fallback)
## 6. [UX backlog (Client UI)](UX-BACKLOG.md)
## 7. [Карта архитектуры для Zed/агентов](ZED-ARCHITECTURE.md)
## 8. [Архив календаря (Фаза 3)](ARCHIVE.md)
## 9. [Гео и карты (Фаза 3)](GEO.md)
## 10. [ИИ клиента (Фаза 3)](PRODUCT-AI.md)
# **Репозитории разработки EventHub**
## 1. [EventHub Backend](https://git.sabilin.com/EventHub/EventHubBack)
+2 -1
View File
@@ -7,8 +7,9 @@ E2E и smoke иногда оставляют commercial-календари с р
1. **Не** удалять «живые» smoke-календари пилота без явного фильтра.
2. Чистить только по безопасным маркерам (название/тег/описание содержат `e2e`, `E2E`, `playwright`, `ift-litter`, или owner = известный e2e user).
3. Soft-delete через API (`DELETE /v1/calendars/:id` владельцем / admin), не ручной wipe Mnesia.
3. Soft-delete через API (`DELETE /v1/calendars/:id` владельцем / admin) для точечного litter; **полный** сброс стенда — Back CI `workflow_dispatch` `wipe-mnesia-ift` / `wipe-mnesia-stage` (`scripts/ci-full-mnesia-wipe.sh`), только вручную, не prod.
4. Front already tracks calendars created in-session via `e2e/helpers/standCleanup.ts` — предпочитать это для новых тестов.
5. Catalog BizCal (`short_name` `bizcal-*`, tag `BizCal`) и disposable LargeCal — не путать с smoke; seed: Front `seed-catalog-ift|stage` (нужны `BIZCAL_CLIENT_*` в `.env` стенда).
## Suggested filter (admin / ops)
+144
View File
@@ -0,0 +1,144 @@
# EventHub — карта для AI-агентов (Zed / Cursor)
Кросс-репо ориентир: где какой модуль и куда класть правки. Детали API — в `EventHubBackSpec.md` / `EventHubFrontSpec.md` / `EventHubFrontAdminSpec.md`. Процесс — `WORKFLOW.md`.
## Репозитории
| Репо | Стек | Зона |
|------|------|------|
| `EventHubBack` | Erlang/OTP 28, Cowboy, Mnesia | Public + Admin API |
| `EventHubFront` | React + Vite + TanStack Query | Клиентский SPA (CalenTIQ) |
| `EventHubFrontAdmin` | React + Vite | Admin SPA |
| `EventHubSpec` | Markdown | Спеки, UI-PARITY, workflow |
| `EventHubAiRouter` | FastAPI + LiteLLM | Zed gateway / routing |
| `EventHubDevOps` | Docker Swarm | Стенды, deploy |
**Правило:** один агент / один PR — один репо. Не смешивать Front и Back в одной сессии правок.
Бренд UI: **CalenTIQ**. Внутренние имена репо `EventHub*` не менять.
## Домен (кратко)
- **User** — владелец календарей / запись на слоты
- **Calendar** — расписание, специалисты, follow
- **Event** — слоты (в т.ч. recurring)
- **Booking / booking-request** — запись / заявка
- **Ticket / Review / Report** — поддержка, отзывы, жалобы
- **Subscription** — подписка владельца
- **Specialist invite** — приглашение специалиста в календарь
- **Admin** — модерация, аудит, stats (отдельный JWT)
Стенды: **dev** локально, **IFT**, **stage** (`https://stage.calentiq.com`, admin `https://admin.stage.calentiq.com`).
---
## EventHubBack (`src/`)
Слои (сверху вниз):
| Слой | Путь | Ответственность |
|------|------|-----------------|
| HTTP | `handlers/`, `handlers/admin/` | Cowboy handlers, валидация входа/ответа |
| Logic | `logic/` | Бизнес-правила, оркестрация core |
| Core | `core/` | Mnesia-таблицы / CRUD сущности |
| Infra | `infra/` | Mnesia, auth helpers, workers, migrations |
| Middleware | `middlewares/` | Auth JWT и т.п. |
### Типичные пары handler → logic → core
| Домен | Handler(s) | Logic | Core |
|-------|------------|-------|------|
| Auth / session | `handler_login`, `handler_register`, `handler_auth`, `handler_refresh`, `handler_verify` | `logic_auth`, `logic_auth_session`, `logic_user` | `core_user`, `core_session`, `core_auth_session`, `core_verification` |
| Password reset | `handler_forgot_password`, `handler_reset_password` | `logic_password_reset` | `core_password_reset` |
| Calendars | `handler_calendars`, `handler_calendar_by_id` | `logic_calendar` | `core_calendar` |
| Specialists | `handler_calendar_specialists`, `handler_specialist_invites`, `handler_calendar_specialist_invites` | `logic_calendar_specialist`, `logic_specialist_invite` | `core_calendar_specialist`, `core_specialist_invite` |
| Follow | `handler_calendar_follow`, `handler_user_following` | `logic_calendar_follow` | `core_calendar_follow` |
| Events | `handler_events`, `handler_event_by_id`, `handler_event_occurrences` | `logic_event`, `logic_recurrence` | `core_event` |
| Bookings | `handler_bookings`, `handler_booking_by_id`, `handler_user_bookings`, `handler_user_booking_requests` | `logic_booking` | `core_booking` |
| Reviews | `handler_reviews`, `handler_review_by_id`, `handler_user_reviews`, `handler_review_vote` | `logic_review` | `core_review`, `core_review_vote` |
| Tickets | `handler_tickets`, `handler_ticket_by_id` | `logic_ticket` | `core_ticket` |
| Reports | `handler_reports` | `logic_report` | `core_report` |
| Search / lookup | `handler_search`, `handler_users_lookup` | `logic_search`, `logic_user_lookup` | — |
| Subscription | `handler_subscription` | `logic_subscription` | `core_subscription` |
| Notifications | — | `logic_notification` | `core_notification` |
| Admin API | `handlers/admin/admin_handler_*` | `logic_admin`, `logic_moderation`, `logic_automoderation`, `logic_stats` | `core_admin`, `core_admin_*`, `core_banned_words`, `core_automod_*` |
Миграции: `src/migrations/`. Сборка/тесты: **только WSL** (`source scripts/wsl-dev-env.sh`, `rebar3 eunit`).
---
## EventHubFront (`src/`)
| Путь | Зачем |
|------|--------|
| `pages/` | Экраны маршрутов |
| `api/*Api.ts` | HTTP к Back |
| `components/calendar/` | UI календаря / workspace |
| `layouts/AppShell.tsx` | Оболочка |
| `store/` | Zustand (auth, mood, locale, calendar context) |
| `hooks/` | React hooks (API, WS, swipe) |
| `lib/` | Утилиты (payloads, schedule, booking display) |
| `i18n/` | Локали |
### Страницы → смысл
| Page | Маршрут (ориентир) | Тема |
|------|-------------------|------|
| `DiscoverPage` | discover | Поиск / лента |
| `CalendarsPage` | calendars | Список календарей |
| `CalendarWorkspacePage` | `/c/:id` | Основной workspace слотов |
| `BookingsPage` | bookings | Мои записи / inbox заявок |
| `ReviewsPage` | reviews | Отзывы |
| `TicketsPage` / `TicketDetailPage` | tickets | Тикеты |
| `SubscriptionPage` | subscription | Подписка |
| `FollowingPage` | following | Подписки на календари |
| `SpecialistInvitesPage` | invites | Инвайты специалиста |
| `ProfilePage` / `MorePage` | profile / more | Профиль / меню |
| `pages/auth/*` | login/register/… | Auth |
npm / Playwright — **только WSL** (см. `.cursor/rules/npm-wsl.mdc`).
---
## EventHubFrontAdmin (`src/`)
| Путь | Зачем |
|------|--------|
| `pages/dashboard` | Дашборд |
| `pages/users`, `calendars`, `events` | Справочники |
| `pages/tickets`, `reports`, `inbox` | Модерация / inbox |
| `pages/reviews`, `automod`, `banned-words` | Контент / автомод |
| `pages/subscriptions`, `admins`, `audit`, `monitoring` | Биллинг / админы / аудит / метрики |
| `api/*Api.ts` | Admin API client |
| `store/authStore.ts` | Admin session |
---
## EventHubAiRouter (`router/`, `config/`)
| Файл / папка | Зачем |
|--------------|--------|
| `router/router.py` | Zed `/v1/chat/completions`, ветка tools vs text |
| `router/hierarchical.py` | Plan (Qwen3.8-Max) / workers / verify (DeepSeek) / plan_confirm |
| `router/agent_hier.py` | При `tools`: Coder-30B executor + tool_calls (Zed Write) |
| `router/orchestrator.py` | Tiers / lanes ABC, Redis session |
| `config/orchestration.yaml` | Модели plan/verify/executor, таймауты, synthetic=never |
| `config/providers.yaml` | Novita / VPN (`novita-planner` = Max) |
| `scripts/gen-litellm-config.py` | Генерация LiteLLM YAML |
IFT: `https://ai-router.ift.calentiq.com/v1`, модель `smart-router`.
Клиент-канон: **Zed Agent** (openai-compatible) → AiRouter; не Claude Code/Codex ACP как primary (обход бюджета).
---
## Куда править (шпаргалка)
| Задача | Репо | Куда смотреть |
|--------|------|----------------|
| Баг API / Mnesia | Back | `handlers``logic``core` |
| UI клиент | Front | `pages` + `api` + `components/calendar` |
| UI админки | FrontAdmin | `pages` + `api` |
| Контракт / процесс | Spec | `*Spec.md`, `WORKFLOW.md`, `design/` |
| Zed routing / hierarchical | AiRouter | `router/*`, `config/orchestration.yaml` |
Перед крупным планом: **утвердить** шаги с пользователем; не раздувать scope (не трогать соседний репо «заодно»).
+18 -15
View File
@@ -20,17 +20,20 @@ SoT: [`boards/calentiq-ui-board-final.png`](boards/calentiq-ui-board-final.png).
| Bookings list §D | список заявок с Confirm/Decline на `/bookings` | `/bookings` = **grouped inbox**: (A) К подтверждению — pending owner/specialist + Confirm/Decline; (B) Мои записи — participant upcoming/past + Cancel; empty groups hidden. Event card Confirm/Decline **остаётся** обязательным |
| Login §D phone chrome | три phone frames + (на борде) Google CTA | brand-first hero + mood `--auth-*`; email/password only; compact mood на auth; taglines — в mood sheet AppShell (не в `/more`); **без** Google OAuth |
| Mood placement | board §C switcher cards | **Front#40/#45:** mood chip в AppShell рядом с BrandWordmark (+ compact auth); mobile chip icon-only (полный CalenTIQ wordmark); **не** в `/more`, **не** в toolbar Month/Week/Day; 3 moods, без flip / CREATOR; switcher glyphs §C (sprout/bolt/briefcase), Time Arc appicon — только favicon |
| Lens + strip | board Today/Upcoming rail only | **Front#39 + controls IA:** lens Обзор/На сегодня/Заявки в **шапке strip** (underline tabs / mobile sheet); view Месяц/Неделя/День рядом с периодом; owner schedule actions отдельным рядом; localStorage only; soft-default по mood |
| Lens + strip | board Today/Upcoming rail only | **Front#46:** lens инфо-табло **под AppShell** (не в ViewMode toolbar); Обзор/Сегодня/Заявки + метрики; soft-default по mood без auto-pin; view Месяц/Неделя/День в calendar chrome; commercial owner: «+ событие» в toolbar, WeekFill во вкладке Студия |
| Week view headers | — | **Front#40:** одна строка day-headers (без дубля `WeekDayColumn`); scroll `.eh-cal-week-scroll` (mobile ~390) |
| Favicon set | 3 отдельных favicon файла на борде | mood appicon через `applyMood` |
| Desktop nav | phone bottom tabs на борде | desktop: **одна строка** chrome — logo + mood \| nav (Календарь / Найти / Записи / Ещё) \| nickname + logout; bottom nav — mobile |
| Header identity | — | nickname primary (не raw email); email только в `title` tooltip |
| Bookings past/expired | — | `expired` бейдж + Cancel скрыт; empty-state с CTA Discover / календари; soft list time |
| Agenda empty copy | — | personal «Нет событий»; commercial «Нет слотов» |
| Agenda empty copy | — | personal «Нет событий»; commercial «Свободных окон нет» |
| Default personal | — | **Front#46 / Back#64:** один personal title `Default` / UI «По умолчанию»; `/default`; overlay только на нём; create UI только Студия |
| Owner rail Team/Studio | — | **Front#46:** фиксированная высота panel; Команда invite свёрнут; Студия = hub (WeekFill + Edit); ScrollRegion стрелки |
| More invites | пункт меню | скрыт при pending=0; badge счётчика при pending > 0 |
| D9 guest specialist schedule | — | **модель B (Front#35):** roster карточек → тап → фильтр слотов по `event.specialist_id`; «Все слоты студии»; book без specialist в body; **не** chip-filter |
| D9 guest specialist schedule | — | **модель B (Front#35):** roster карточек → тап → фильтр слотов по `event.specialist_id`; «Все слоты студии»; book без specialist в body; **не** chip-filter; в «Все» — studio aggregate occupancy |
| Confirmed на personal | — | Front-only overlay confirmed чужих commercial на default personal; title `событие · студия · спец`; клик → `/c/:foreignCalId/e/:eventId` |
| SpecialistsPanel density | — | mobile: имя/теги сверху, actions снизу full-width (Front#33/#35) |
| SpecialistsPanel density | — | mobile: имя/теги сверху, actions снизу full-width (Front#33/#35); owner первым + бейдж «Владелец» + галочка «Я специалист» (Front#48) |
| Studio / Мастер commercial grid | board §E denser studio | Owner: Студия (агрегат `start_time`+`duration`, `K/N свободно`, popover) / Мастер (roster filter); месяц — density markers; rail Расписание\|Команда\|Студия; `booking_occupancy` из API; ★ 0.0 на free не показывать; personal без агрегата |
| Stage | полный smoke на stage | **done** 2026-07-27: Login×3 / Calendar×3 + agenda / Discover / CONTROL commercial + Confirm·Decline / More moods |
| Домены calentiq.* | — | публичный stage: `stage.calentiq.com` / `admin.stage.calentiq.com` (канон STAGE-BROWSER.md) |
@@ -88,7 +91,7 @@ API: `GET /v1/user/booking-requests` (Back) + существующий `PUT /v1/
изменений.
3. **SpecialistsPanel density (mobile):** имя/теги сверху, actions снизу full-width.
### Mood + Lens (locked Front#39 / placement Front#40)
### Mood + Lens (locked Front#39/#40; dashboard Front#46)
1. **Mood** = визуальная атмосфера (`html[data-mood]`, FLOW/MOMENTUM/CONTROL). Primary UX —
chip → sheet в **AppShell** рядом с BrandWordmark (Front#40); compact на auth.
@@ -97,16 +100,15 @@ API: `GET /v1/user/booking-requests` (Back) + существующий `PUT /v1/
полный CalenTIQ wordmark; desktop — полный label; sheet — §C glyph + label + tagline.
Glyphs: FLOW sprout, MOMENTUM bolt, CONTROL briefcase (не Time Arc appicon).
2. **Lens** = отдельный контентный режим (`overview` / `today` / `bookings`), не mood и не
view month/week/day. UI: Обзор / На сегодня / Заявки. Переключатель — в шапке strip
(underline/text tabs на desktop; chip → sheet на mobile), **не** рядом с ViewMode.
Soft-default при смене mood, если lens не pinned: FLOW→overview, MOMENTUM→today,
CONTROL→bookings.
3. **Strip** над сеткой (не на HTML-архиве месяца): Front-only derive — free gaps /
события сегодня / upcoming bookings. Desktop раскрыт по умолчанию; mobile свёрнут
(tap раскрывает). Сетка всегда видна. Workspace toolbar: контекст (селектор) /
виджет (период + view) / owner schedule actions (отдельный ряд).
4. **Persistence:** mood → DB; language → `user.language`; lens → `localStorage`
(`eh.calendar.lens` + pinned). Без нового Back API.
view month/week/day. UI: Обзор / Сегодня / Заявки. **Front#46:** инфо-табло **под
AppShell** (surface + сегменты + метрики/chips), **не** рядом с ViewMode и не в
calendar toolbar. Soft-default при смене mood **без auto-pin** при клике:
FLOW→overview, MOMENTUM→today, CONTROL→bookings.
3. Контент lens (не на HTML-архиве): Front-only derive — free gaps / события сегодня /
upcoming bookings. Workspace toolbar: селектор / период+view / «+ Новое событие»
(WeekFill — во вкладке Студия).
4. **Persistence:** mood → DB; language → `user.language`; lens — session + soft LS
(без вечного pin от клика). Без нового Back API.
5. **Week view (Front#40):** одна строка day-headers (без дубля `WeekDayColumn`);
горизонтальный скролл `.eh-cal-week-scroll` (mobile ~390).
@@ -121,6 +123,7 @@ API: `GET /v1/user/booking-requests` (Back) + существующий `PUT /v1/
| D9 B + confirmed personal overlay | **done** Front#35 (`57cfcf3`) |
| Mood chip + Lens strip | **done** Front#39 (`c2cb754` / `d38a927`); chip → AppShell Front#40; mobile icon-only Front#45 |
| Week view single header + scroll | **done** Front#40 (`db11c41`) |
| Studio / Мастер commercial grid + `booking_occupancy` | Spec: FrontSpec §3.1/§5.4 + BackSpec events; aggregate по `start_time`+`duration`, rail Расписание\|Команда\|Студия; follow-up visual polish board §E |
## Mood concept = product surface