Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99855dc2f6 | |||
| 59726d394c | |||
| b79fd81260 | |||
| 588a282b6d | |||
| 65afff6629 | |||
| 4a96834d8e | |||
| 14da77fd7e | |||
| f68f39f5f9 | |||
| 9b4499e884 | |||
| 4dd741ba48 | |||
| 1182568c9d | |||
| 515db26a0c | |||
| 3189390794 | |||
| 07e89052fe | |||
| 02e49a40f5 | |||
| dbabc4e8f3 | |||
| 436e0a70c0 | |||
| 0fa213a1f8 | |||
| 28e2795199 | |||
| 938a022ad8 | |||
| d451753be2 | |||
| d7c5ae3ac4 | |||
| 1bed282cfb | |||
| 5fa00a44bf | |||
| 80e10caf3d | |||
| e87236b981 | |||
| 8e8e361e77 | |||
| 73ff212020 | |||
| e611f6b92c | |||
| bc1d770f16 | |||
| 951d070d63 | |||
| 94170ebd13 | |||
| 2ec5d1b6d6 | |||
| 4d8f4db284 | |||
| a93997b0bb | |||
| 9dfc4f0097 | |||
| 2fa4635a9f | |||
| 9d79babac5 | |||
| 89073c9e76 | |||
| 8451bb352d | |||
| 86e53b5f0b | |||
| a7132b9b64 | |||
| a81262b3e0 | |||
| 8dae666119 | |||
| 96f320cabc | |||
| 644ded803c | |||
| 546f888487 | |||
| 7c21fa2aef | |||
| cc469d5698 | |||
| 7a6b1a84cc | |||
| 01904e9873 | |||
| a60a939566 | |||
| f62071b86b | |||
| 3bc3bd4236 | |||
| d3a011b746 | |||
| 567090ebcc | |||
| 862af12208 | |||
| 8825a24dde | |||
| 85201075c2 | |||
| f2b200a0f5 | |||
| ab1a1e6dba | |||
| 5e3c5cb691 | |||
| 1f6bc3decc | |||
| 6f88205675 | |||
| 22360a66f7 |
@@ -0,0 +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` |
|
||||
| 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` + 1–2 файла |
|
||||
| Полный `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 в каждом чате.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Ответы субагентов и итоги работы — на русском
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EventHub: язык ответов агентов
|
||||
|
||||
## Правило
|
||||
Все **вызванные / фоновые агенты** (Task subagents) пишут пользователю и parent-агенту **на русском**:
|
||||
- итоги, вердикты, чеклисты, UX-оценки;
|
||||
- комментарии в Gitea (как и раньше — только русский);
|
||||
- краткие status-апдейты в конце задачи.
|
||||
|
||||
## Допустимо на английском
|
||||
- Идентификаторы кода, API paths, commit subject если в репо уже принят EN-стиль сообщений (можно и RU — главное единообразие в рамках задачи);
|
||||
- цитаты из логов CI / stack traces без перевода;
|
||||
- имена файлов, SHA, URL.
|
||||
|
||||
## Родитель-координатор
|
||||
Тоже отвечает пользователю по-русски.
|
||||
|
||||
## Промпт субагенту (обязательно)
|
||||
- «Return summary in Russian»
|
||||
- «Shell: WSL + `.sh` only — `shell-wsl-not-powershell.mdc`, `agent-pitfalls.mdc`»
|
||||
- «One repo per agent — `one-repo-per-agent.mdc`»
|
||||
- «Экономия токенов: §6 agent-pitfalls — узкий scope, без лишних subagents/explore»
|
||||
- Не push / не `git clean -fdx` без явного OK пользователя
|
||||
@@ -0,0 +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`.
|
||||
|
||||
Front IFT e2e: не голый `npm run test:e2e:ift` (получите skipped). Нужны `SMOKE_USER_*` — `run-front-ift.sh` / `eventhub-ift.env`.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
description: Один агент — один репозиторий; не смешивать Front и Back
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EventHub: отдельные агенты по репозиториям
|
||||
|
||||
## Правило
|
||||
При работе с **несколькими репозиториями** EventHub запускай **отдельного агента на каждый репо**. Не смешивай Front и Back (и Spec/FrontAdmin) в одном агенте.
|
||||
|
||||
| Репо | Зона |
|
||||
|------|------|
|
||||
| `EventHubFront` | клиентский SPA |
|
||||
| `EventHubBack` | Erlang API |
|
||||
| `EventHubSpec` | спеки, UI-PARITY, контракты |
|
||||
| `EventHubFrontAdmin` | admin SPA |
|
||||
|
||||
## Запрещено
|
||||
- Один агент правит и Front, и Back в одной сессии
|
||||
- Коммиты/`Refs`/`Fixes` не в тот репо (Front-код → issue Back, и наоборот)
|
||||
- Закрывать/комментировать issue чужого репо «заодно»
|
||||
|
||||
## Как делать
|
||||
1. Спека/issue → агент **Spec** (или короткий foreground), если только доки.
|
||||
2. API → отдельный агент **Back** (`Back#N`, eunit, push Back).
|
||||
3. UI → отдельный агент **Front** (`Front#N`, lint/IFT, push Front).
|
||||
4. Родитель-координатор только маршрутизирует; не пишет код сразу в два репо.
|
||||
|
||||
Кросс-репо фича (например Bookings inbox): **сначала Back**, потом **Front** (или параллельно двумя агентами), Spec — третьим или после стабилизации API.
|
||||
@@ -0,0 +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/`.
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
description: Как открыть stage UI в Cursor Browser (alias + proxy)
|
||||
description: Stage UI в Cursor Browser — прямой HTTPS; proxy только DNS-fallback
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Stage browser (Cursor)
|
||||
|
||||
`*.eventhub.local` часто не резолвится в Chromium/Cursor Browser.
|
||||
**Канон:** прямой HTTPS с валидным TLS/DNS:
|
||||
|
||||
Канон: **`http://ui.stage.eventhub.test:8787`** через локальный proxy.
|
||||
- Client: `https://stage.calentiq.com/`
|
||||
- Admin: `https://admin.stage.calentiq.com/`
|
||||
|
||||
Полная инструкция: `EventHubSpec/STAGE-BROWSER.md`
|
||||
Скрипт: `EventHubSpec/scripts/stage-browser-alias-proxy.mjs` (запуск **Windows Node** на `:8787`).
|
||||
Локальный proxy `http://ui.stage.eventhub.test:8787` (`scripts/stage-browser-alias-proxy.mjs`) — **только fallback**, если Cursor/Chromium не резолвит hostname. Не из‑за сертификата.
|
||||
|
||||
Hosts: `127.0.0.1 ui.stage.eventhub.test`
|
||||
Полная инструкция: `EventHubSpec/STAGE-BROWSER.md`
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
/.idea/
|
||||
/EventHubSpec.iml
|
||||
/.idea/
|
||||
/EventHubSpec.iml
|
||||
|
||||
# Temporary/scratch files
|
||||
.tmp-*
|
||||
|
||||
@@ -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 | 7–14 дней |
|
||||
| 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 день) | ~1100–2000 |
|
||||
| закрытый месяц после grace | **0** исторических месяцев |
|
||||
|
||||
100 таких студий: 90д lookback ≈ **0.5M** лишних hot-строк в `disc_copies` на **каждый** узел кластера. Warm-blob 24 месяцев в Mnesia тоже тяжёлый (~1 MB gzip/мес × 24 × 100 ≈ 2 GB disc_only). Три месяца warm ≈ **300 MB** на ту же сотню.
|
||||
|
||||
### Дефолты (зафиксировано)
|
||||
|
||||
- **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-флага).
|
||||
@@ -0,0 +1,53 @@
|
||||
# CalenTIQ branding (серия 0.2)
|
||||
|
||||
Краткие зафиксированные решения для UI / публичного бренда. Внутренние имена репозиториев (`EventHub*`) не меняем.
|
||||
|
||||
## Публичный бренд
|
||||
|
||||
- **Имя:** CalenTIQ
|
||||
- **Смысл:** calendar + TIQ (time intelligence)
|
||||
- **Tagline (направление):** One calendar. Three modes for your time.
|
||||
- **Логотип:** Time Arc (дуга часов + равные точки слотов); master vector в [`design/front/logo/`](design/front/logo/)
|
||||
|
||||
## Moods (UI-лейблы)
|
||||
|
||||
Внутренние ключи API/preferences пока: `calm` | `energetic` | `business`.
|
||||
|
||||
| Ключ (API) | Публичный лейбл | Акцент |
|
||||
|------------|-----------------|--------|
|
||||
| `calm` | FLOW | sage `#ABC5B5` / primary `#2F7A68` |
|
||||
| `energetic` | MOMENTUM | coral `#FF6A3D` на ink |
|
||||
| `business` | CONTROL | steel `#324A5F` |
|
||||
|
||||
IA без изменений: top nav + bottom nav mobile; без left sidebar; без 4-го Creator mood.
|
||||
|
||||
## Design / Brand pack SoT
|
||||
|
||||
Канон в Spec (клиентский фронт):
|
||||
|
||||
- [`design/front/`](design/front/) — UI-борды, Time Arc (mark/lockup), mood icons, tokens
|
||||
- [`design/front/README.md`](design/front/README.md) — опись файлов и IA
|
||||
- [`design/front/UI-PARITY.md`](design/front/UI-PARITY.md) — план visual parity (mood как product surface; workstreams + non-goals)
|
||||
- [`design/admin/`](design/admin/) — зарезервировано под админку (пока пусто)
|
||||
|
||||
Runtime-копии в приложении: `EventHubFront/public/brand/` (mark, lockup, favicons, mood app icons).
|
||||
Локальные ZIP (`calentiq-brand-pack-local.zip` и т.п.) — рабочие сборки; в git SoT не кладём.
|
||||
|
||||
## Domain / DNS (чеклист, `calentiq.com`)
|
||||
|
||||
Публичный бренд ≠ внутренние хосты `*.eventhub.local` / `*.eventhub.test` (DevOps).
|
||||
**Канон apex:** `calentiq.com` (куплен, в использовании на stage / IFT / redirects).
|
||||
|
||||
Проверка 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: Let’s 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
|
||||
|
||||
https://git.sabilin.com/EventHub/EventHubSpec/issues/10
|
||||
@@ -13,23 +13,44 @@ 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
|
||||
- Теги календаря, рейтинг (средняя оценка, количество голосов)
|
||||
- После успешной верификации email (`POST /v1/verify`, статус пользователя → `active`) система
|
||||
идемпотентно создаёт дефолтный **personal**-календарь владельцу (`logic_calendar:ensure_default_calendar/1`):
|
||||
название — `nickname` или «Мой календарь», `confirmation=manual`. Повторный вызов не создаёт дубликат,
|
||||
если у пользователя уже есть active personal-календарь. Существующим пользователям без календаря
|
||||
backfill не выполняется.
|
||||
идемпотентно нормализует **единственный personal** владельца
|
||||
(`logic_calendar:ensure_default_calendar/1` → `normalize_owner_personals/1`):
|
||||
- title в БД — **`Default`** (EN); UI — i18n «По умолчанию»;
|
||||
- `confirmation=manual`;
|
||||
- если personal нет — создаёт; если есть — оставляет **самый ранний** (`created_at`), title → `Default`;
|
||||
- лишние personal: с событиями/специалистами → `type=commercial` (system path, без требования
|
||||
subscription); пустые → soft-delete.
|
||||
- Повторный вызов идемпотентен. Миграция `20260730200000_single_default_personal` —
|
||||
`backfill_single_personal/0` для всех владельцев.
|
||||
- `POST /v1/calendars` с `type=personal` при уже существующем personal → **409**
|
||||
`{error: "personal_exists"}`.
|
||||
- `DELETE` единственного personal → **403** `{error: "default_calendar"}`.
|
||||
- `PUT` `personal → commercial` на единственном personal → **403**
|
||||
`{error: "default_calendar"}` (новый бизнес — отдельный create commercial).
|
||||
|
||||
**Новые поля (задача #12):**
|
||||
- `short_name` — короткое уникальное имя для API и поиска
|
||||
- `category` — категория (enum)
|
||||
- `color` — цвет отображения
|
||||
- `image_url` — изображение календаря
|
||||
- `settings` — дополнительные настройки (map)
|
||||
- `settings` — дополнительные настройки (map). Известные org-default ключи
|
||||
(валидируются на PUT/PATCH; неизвестные ключи — passthrough, напр. `week_patterns`):
|
||||
- `default_location` — `{ "address": "…", "lat"?: number, "lon"?: number }`;
|
||||
`address` — непустая строка; `lat`/`lon` — оба или ни одного (числа)
|
||||
- `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)
|
||||
|
||||
### 2.1.1. Специалисты календаря
|
||||
Таблица `calendar_specialist`: связь пользователя-специалиста с **commercial**-календарём.
|
||||
@@ -48,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` на событии |
|
||||
@@ -78,14 +99,17 @@ EventHub — платформа для управления событиями
|
||||
слоты освобождаются;
|
||||
- уже `confirmed` — **оставляем**;
|
||||
- owner: просмотр/редактирование своих событий и настроек, CRUD specialists — **разрешены**;
|
||||
- create нового commercial / upgrade type → `402`.
|
||||
- create нового commercial / upgrade type → `402` (нужна **уже active** подписка
|
||||
или trial; auto-start trial при create **нет** — см. ниже и §2.9).
|
||||
|
||||
Периодический job (например раз в 60 с): помечает просроченные подписки `expired`, отменяет
|
||||
pending владельца, **не** меняет `calendar.type`. Legacy `downgrade_user_calendars/1`
|
||||
(type → personal) — удалить/не использовать.
|
||||
|
||||
Первое создание commercial без подписки: **auto-start trial** (один раз, `trial_used`);
|
||||
повтор без подписки → `402`. Планы/цены — §2.9.
|
||||
Create / upgrade `commercial` без **уже active** подписки или trial → **`402`**.
|
||||
Trial стартует **только** явным `POST /v1/subscription` с `action=start_trial`
|
||||
(один раз, `trial_used`); при create commercial auto-start trial **нет**.
|
||||
Планы/цены — §2.9.
|
||||
|
||||
В ответах календаря (user API): `booking_open` (boolean) — производное от
|
||||
`type=commercial` ∧ calendar `active` ∧ подписка владельца active.
|
||||
@@ -94,24 +118,64 @@ pending владельца, **не** меняет `calendar.type`. Legacy `downg
|
||||
|
||||
- `POST /v1/events/:id/bookings` только если календарь события commercial, `booking_open=true`,
|
||||
событие `active`, есть свободная вместимость.
|
||||
- Для **recurring** master в теле обязательно `occurrence_start` (ISO8601). Без поля → `400`
|
||||
(`occurrence_start required`). Невалидное или отменённое вхождение → `400`.
|
||||
Back материализует instance (`is_instance=true`, `master_id`) и вешает booking на его `id`.
|
||||
Для `single` / уже материализованного instance тело опционально, `occurrence_start` игнорируется.
|
||||
- **Pending занимает capacity** наравне с confirmed (защита от overbook при auto/timeout).
|
||||
- Capacity: число booking со статусом `pending` | `confirmed`; `cancelled` не считаются.
|
||||
- Capacity: число booking со статусом `pending` | `confirmed`; `cancelled` и `expired` не считаются.
|
||||
- Политика `confirmation` календаря при создании booking:
|
||||
- `auto` → сразу `confirmed` + `confirmed_at`;
|
||||
- `manual` → `pending`; confirm/decline — владелец или specialist (см. ниже);
|
||||
- `{timeout, N}` → `pending`; через N секунд без решения: auto-confirm, если ещё есть
|
||||
capacity, иначе `cancelled` (`timeout_full`).
|
||||
- Участник: `DELETE /v1/bookings/:id` — отмена своей pending/confirmed.
|
||||
- **Past-pending → `expired`:** если событие уже началось (`now >= event.start_time`), а booking
|
||||
ещё `pending`, статус переводится в `expired` (lazy при чтении списков/`GET` booking и в
|
||||
`process_timeout_bookings`). `expired` не actionable: Confirm/Decline → `409` (`Booking expired`).
|
||||
- **Inbox:** `GET /v1/user/booking-requests` возвращает только ещё actionable pending
|
||||
(до старта события); past-pending помечает `expired` и **не** включает в ответ.
|
||||
- Участник: `DELETE /v1/bookings/:id` — отмена своей pending/confirmed; для уже `expired` —
|
||||
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-календарю после **принятия приглашения**.
|
||||
- Специалист = существующий `user`, привязанный к commercial-календарю
|
||||
(`calendar_specialist`, `status` active|inactive).
|
||||
- **Не путать** с `calendar_share` (права read/write/admin — фаза 2).
|
||||
|
||||
##### Owner как specialist (одиночки)
|
||||
|
||||
- При **create** `commercial` (и при системном upgrade personal→commercial) бэкенд
|
||||
**идемпотентно** создаёт строку `calendar_specialist` на `owner_id`:
|
||||
`status=active`, `name` из nickname (иначе email), `specialization=[]`.
|
||||
- Owner-строка **неудаляема**: `DELETE /v1/calendars/:id/specialists/:owner_id` →
|
||||
`403` (`Owner specialist cannot be removed` / `owner_specialist_protected`).
|
||||
- «Убрать себя из специалистов» = `PUT` с `status=inactive` (продуктовая галочка);
|
||||
снова включить — `status=active`. Редактируются также `name` / `specialization`.
|
||||
- Invite себе **не** требуется. Миграций/backfill старых календарей нет (wipe БД
|
||||
на стендах при деплое).
|
||||
|
||||
##### Приглашение (`specialist_invite`)
|
||||
|
||||
Владелец commercial **не** вводит сырой `user_id` в продуктовом UI. Добавление — через invite:
|
||||
Владелец commercial **не** вводит сырой `user_id` в продуктовом UI для **других**
|
||||
специалистов. Добавление команды — через invite:
|
||||
|
||||
| Канал | Как |
|
||||
|-------|-----|
|
||||
@@ -155,7 +219,10 @@ pending владельца, **не** меняет `calendar.type`. Legacy `downg
|
||||
- `POST /v1/calendars/:id/specialist-invites` — тело: `{ user_id }` **или** `{ email }`,
|
||||
опционально `name`, `specialization`
|
||||
- `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отмена pending (`cancelled`)
|
||||
- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / remove уже принятого
|
||||
- `PUT /v1/calendars/:id/specialists/:user_id` — update `name` / `specialization` / `status`
|
||||
(в т.ч. owner)
|
||||
- `DELETE /v1/calendars/:id/specialists/:user_id` — remove принятого; для
|
||||
`user_id = owner_id` → **403** (см. Owner как specialist)
|
||||
|
||||
Invitee:
|
||||
|
||||
@@ -167,18 +234,67 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
допускается только как внутренний/тестовый путь или удаляется после миграции на invite.
|
||||
Продуктовый путь: invite → accept → specialist.
|
||||
|
||||
- `event.specialist_id` опционален; если задан — только `active` specialist этого календаря
|
||||
(иначе `400`).
|
||||
- `event.specialist_id` опционален на `POST`/`PUT` события; если задан — персистится
|
||||
и валидируется: только `active` specialist этого календаря (иначе `400`
|
||||
`Invalid specialist_id for this calendar`).
|
||||
- **Confirm/decline booking:** владелец — любые booking календаря; `active` specialist — только
|
||||
booking на событиях, где `event.specialist_id` = его `user_id`.
|
||||
- **Inbox к подтверждению:** `GET /v1/user/booking-requests` агрегирует **actionable** pending
|
||||
по тем же правилам (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. События (расширенная версия с повторяющимися событиями)
|
||||
|
||||
@@ -202,12 +318,11 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
- Возвращать как одиночные, так и сгенерированные вхождения в едином списке.
|
||||
|
||||
#### 2.2.3. Материализация при записи участника
|
||||
При записи участника на конкретное вхождение повторяющегося события:
|
||||
- Система материализует (создаёт) физическую запись события для этого вхождения, если оно ещё
|
||||
не было материализовано (например, для хранения количества записавшихся).
|
||||
- Материализованное событие имеет `is_instance = true` и ссылается на `master_id`.
|
||||
- Запись участника (`booking`) всегда привязывается к конкретному экземпляру (материализованному
|
||||
или одиночному событию).
|
||||
`POST /v1/events/:id/bookings` на мастер серии:
|
||||
- в теле JSON: `occurrence_start` — время вхождения;
|
||||
- если instance с этим `start_time` ещё нет — создаётся (`is_instance=true`, `master_id`);
|
||||
- `booking.event_id` — id материализованного вхождения, не master.
|
||||
Одиночные события бронируются как раньше (тело может быть пустым `{}`).
|
||||
|
||||
#### 2.2.4. Изменение и удаление серий
|
||||
- При редактировании мастера можно применить изменения ко всем будущим экземплярам или создать
|
||||
@@ -245,12 +360,17 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
- Запись доступна только на событиях **commercial**-календаря с `booking_open=true` (§2.1.2).
|
||||
- На personal → `403` (`personal_calendar`); при restricted commercial → `403` (`subscription_inactive`).
|
||||
- В зависимости от `confirmation` календаря: auto / manual / timeout (§2.1.2).
|
||||
- Статусы: `pending`, `confirmed`, `cancelled`.
|
||||
- **Capacity:** лимит слотов считают `pending` + `confirmed` (pending резервирует место).
|
||||
- Участник может отменить свою запись (`DELETE`).
|
||||
- Статусы: `pending`, `confirmed`, `cancelled`, `expired`.
|
||||
- **Capacity:** лимит слотов считают `pending` + `confirmed` (pending резервирует место;
|
||||
`cancelled` / `expired` не занимают).
|
||||
- **Истечение:** past-pending (событие уже началось) → `expired` (lazy + timeout job); не
|
||||
путать с `specialist_invite.status=expired`.
|
||||
- Участник может отменить свою запись (`DELETE`) для pending/confirmed; `expired`/`cancelled` —
|
||||
идемпотентный успех.
|
||||
- Confirm/decline: владелец календаря — любые заявки; active specialist — только на событиях
|
||||
со своим `specialist_id` (§2.1.2).
|
||||
со своим `specialist_id` (§2.1.2). На `expired` (и после lazy-mark) → `409`.
|
||||
- При подтверждении фиксируется `confirmed_at`.
|
||||
- Inbox `GET /v1/user/booking-requests` — только pending до старта события (§2.1.2).
|
||||
|
||||
### 2.4. Отзывы и рейтинги
|
||||
- Пользователи могут оставлять отзывы (рейтинг 1–5 и комментарий) на события или календари.
|
||||
@@ -285,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`) на календари, события, отзывы.
|
||||
@@ -323,10 +463,16 @@ 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 стартует автоматически при
|
||||
первой попытке создать/апгрейднуть commercial (§2.1.2).
|
||||
- Отслеживание использования пробного периода (`trial_used`); trial стартует **только**
|
||||
явным `POST /v1/subscription` (`action=start_trial`), не при create/upgrade commercial
|
||||
(§2.1.2). Create/upgrade commercial без уже active sub/trial → `402`.
|
||||
- Истечение: периодический job → `expired`; commercial-календари **не** меняют type —
|
||||
переходят в restricted; после `activate` / новой active подписки функционал восстанавливается
|
||||
(§2.1.2). Не использовать legacy downgrade type → personal.
|
||||
@@ -382,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. Надёжность
|
||||
@@ -397,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-формате.
|
||||
@@ -426,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 — точка входа приложения
|
||||
```
|
||||
@@ -443,47 +590,106 @@ 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`; смена пароля —
|
||||
пара `current_password` + `password` (неверный текущий → `403`). Нельзя менять
|
||||
email/role/status; неизвестные поля → `400`. Ответ — полный профиль как GET.
|
||||
- `GET /v1/user/bookings` — бронирования пользователя.
|
||||
- `GET /v1/user/bookings` — бронирования пользователя **как участника**.
|
||||
- `GET /v1/user/booking-requests` — **actionable** pending-заявки к подтверждению, где
|
||||
текущий пользователь — **owner** календаря события или **assigned specialist**
|
||||
(`event.specialist_id` = user и specialist active). Учитываются и материализованные
|
||||
occurrence (`is_instance`). Past-pending помечается `expired` и **не** попадает в ответ.
|
||||
- `GET /v1/user/studio-bookings` — pending **и confirmed** на тех же календарях (журнал студии).
|
||||
Форма ответа как у booking-requests (`role` + вложенный `event`).
|
||||
- `GET /v1/user/reviews` — отзывы пользователя.
|
||||
- `GET /v1/user/following` — календари, которые пользователь отслеживает (follow).
|
||||
- `GET /v1/search` — поиск; пустой запрос (только auth + пагинация/`type`) — discovery tops.
|
||||
- `GET /v1/calendars` — список календарей.
|
||||
- `POST /v1/calendars` — создать календарь (`commercial` → подписка/trial; иначе `402`).
|
||||
- `GET /v1/calendars/:id` — календарь (`following`, `booking_open` для текущего контекста).
|
||||
- `PUT /v1/calendars/:id` — обновить календарь (`personal→commercial` → gate подписки).
|
||||
- `GET /v1/search` — поиск; **без токена** (гость) — только commercial по `can_access`;
|
||||
пустой запрос + пагинация/`type` — discovery tops. Personal **никогда** не в выдаче
|
||||
(в т.ч. свои). С Bearer — commercial + доступные; busy для Time Arc берётся из personal отдельно.
|
||||
В calendar-результатах — `image_url` (если задан на календаре). Upload/multipart
|
||||
**не** входит в контракт: поле URL.
|
||||
Гео: `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` — календарь **или unique `short_name`**. **Без токена** для active commercial (`following: false`);
|
||||
personal без доступа → `403`. С сессией: `following`, `booking_open`.
|
||||
- `PUT /v1/calendars/:id` — обновить календарь (`personal→commercial` → нужна уже
|
||||
active sub/trial, иначе `402`).
|
||||
- `DELETE /v1/calendars/:id` — удалить календарь.
|
||||
- `POST /v1/calendars/:id/follow` — отслеживать чужой календарь.
|
||||
- `DELETE /v1/calendars/:id/follow` — снять follow.
|
||||
- `GET /v1/users/lookup?q=` — typeahead пользователей для invite (минимальный PII, rate-limit).
|
||||
- `GET /v1/calendars/:id/specialists` — список специалистов (владелец).
|
||||
- `GET /v1/calendars/:id/specialists` — список специалистов (гость на commercial — да;
|
||||
personal — как `can_access`).
|
||||
- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / убрать специалиста (владелец).
|
||||
- `GET/POST /v1/calendars/:id/specialist-invites` — исходящие invite / создать (владелец).
|
||||
- `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отменить pending (владелец).
|
||||
- `GET /v1/user/specialist-invites` — входящие приглашения.
|
||||
- `POST /v1/specialist-invites/:id/accept` | `…/decline` — ответ invitee.
|
||||
- `POST /v1/specialist-invites/accept` `{ token }` — accept по email deep-link.
|
||||
- `GET /v1/calendars/:calendar_id/events` — события календаря.
|
||||
- `POST /v1/calendars/:calendar_id/events` — создать событие.
|
||||
- `GET /v1/events/:id` — событие.
|
||||
- `PUT /v1/events/:id` — обновить событие.
|
||||
- `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"`.
|
||||
Считается по **active** bookings события (`pending` | `confirmed`);
|
||||
`cancelled` / `expired` **не** дают занятость. Приоритет агрегата:
|
||||
`confirmed` > `pending` > `free` (если есть хотя бы один confirmed →
|
||||
`"confirmed"`; иначе если есть pending → `"pending"`; иначе `"free"`).
|
||||
Для personal: поле можно omit или всегда `"free"`.
|
||||
Virtual occurrences (expand списка): occupancy по **event id в ответе**
|
||||
(материализованный instance, если он уже есть и его id отдан; иначе —
|
||||
тот id, с которым Back отдаёт вхождение — обычно master / шаблон —
|
||||
bookings смотрятся по этому id).
|
||||
- `POST /v1/calendars/:calendar_id/events` — создать событие (тело может включать
|
||||
опциональный `specialist_id`; invalid → `400`).
|
||||
- `GET /v1/events/:id` — событие (тот же контракт `booking_occupancy`, что
|
||||
у списка events выше).
|
||||
- `PUT /v1/events/:id` — обновить событие (в т.ч. `specialist_id`; invalid → `400`).
|
||||
- `DELETE /v1/events/:id` — удалить событие.
|
||||
- `GET /v1/events/:id/occurrences` — вхождения повторяющегося события.
|
||||
- `DELETE /v1/events/:id/occurrences/:start_time` — отменить вхождение серии.
|
||||
- `POST /v1/events/:id/bookings` — запись на событие.
|
||||
- `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` — подтвердить/отклонить бронирование (владелец).
|
||||
- `DELETE /v1/bookings/:id` — отменить бронирование (участник).
|
||||
- `PUT /v1/bookings/:id` — подтвердить/отклонить (`confirm`|`decline`); владелец —
|
||||
любые booking календаря; active specialist — только события со своим `specialist_id`.
|
||||
На past-pending / уже `expired` → `409` (`Booking expired`); при полной вместимости на
|
||||
confirm → `409` (`Event is full`).
|
||||
- `DELETE /v1/bookings/:id` — отменить бронирование (участник; `expired`/`cancelled` — no-op `200`).
|
||||
- `POST /v1/reviews` — создать отзыв.
|
||||
- `GET /v1/reviews` — список отзывов (поле `my_vote` для текущего пользователя).
|
||||
- `GET /v1/reviews/:id` — отзыв по ID (`my_vote`).
|
||||
@@ -497,8 +703,10 @@ src/
|
||||
- `POST /v1/tickets` — создать или обновить тикет (дедуп по `error_hash`): body `{ error_message, stacktrace?, context?, source?: frontend|manual }`; ответ 201 (или 429 при rate-limit новых).
|
||||
- `GET /v1/tickets/:id` — статус тикета.
|
||||
- `GET /v1/subscription` — подписка пользователя.
|
||||
- `POST /v1/subscription` — активировать подписку.
|
||||
- `GET /v1/calendars/:calendar_id/view?month=YYYY-MM` — HTML-календарь (владелец), включая архив.
|
||||
- `POST /v1/subscription` — `action=start_trial` | `activate` (+ `plan`, опционально
|
||||
`payment_info`). Trial — только через `start_trial`, не через create commercial.
|
||||
- `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).
|
||||
@@ -508,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 (стенды).
|
||||
@@ -546,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`.
|
||||
@@ -610,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 2 MiB) → 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
|
||||
|
||||
@@ -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. Технологический стек
|
||||
@@ -37,8 +41,8 @@ Deep-link на календарь, владельцем которого явл
|
||||
- Язык: TypeScript
|
||||
- Сборщик: Vite
|
||||
- UI: React 19 + Tailwind CSS 4 + shadcn/Radix
|
||||
- Типографика: Syne (display) + Manrope (UI)
|
||||
- Состояние: Zustand (auth + mood + calendar context), TanStack React Query v5 (сервер)
|
||||
- Типографика: **Syne** только для логотипа CalenTIQ (латиница); UI, заголовки, даты и время — **Manrope** (кириллица)
|
||||
- Состояние: Zustand (auth + mood + lens + calendar context), TanStack React Query v5 (сервер)
|
||||
- HTTP: Axios с интерсепторами
|
||||
- Формы: React Hook Form + Zod
|
||||
- Дата/время: dayjs (ru locale)
|
||||
@@ -51,58 +55,160 @@ Deep-link на календарь, владельцем которого явл
|
||||
|
||||
- Главная сущность — **календарь**. Центр UI — виджет с видами **месяц** (default) / **неделя** / **день**.
|
||||
- Главные вкладки: **Календарь** (`/`, `/c/:id`), **Найти** (`/search`), **Записи** (`/bookings`), **Ещё** (`/more`).
|
||||
- Контекст виджета: свой календарь (селектор) или чужой (browse после поиска). Чужой `personal` —
|
||||
- **Записи** (`/bookings`) — grouped inbox: «К подтверждению» (owner/specialist pending + Confirm/Decline)
|
||||
и «Мои записи» (participant); пустые группы скрывать. Confirm на карточке события остаётся.
|
||||
Past/expired: бейдж `expired` («истекла»), Cancel скрыт; empty-state с CTA Discover / календари.
|
||||
Время на list-карточках — вторичное к title (`.eh-book-list-time`).
|
||||
- В **Ещё**: язык (`LanguageSwitcher`); пункт входящих specialist invites скрыт при
|
||||
`pending=0`; при `pending > 0` — badge со счётчиком (deep-link `/invites` работает).
|
||||
**Mood в Ещё нет** — chip в AppShell рядом с BrandWordmark (Front#40; ранее Front#39
|
||||
в chrome workspace).
|
||||
- **Mood + Lens (Front#39/#40 + Front#46):** mood chip (FLOW/MOMENTUM/CONTROL → sheet picker) —
|
||||
в **AppShell** рядом с логотипом (**не** в toolbar Month/Week/Day). **Lens** — инфо-табло
|
||||
**под строкой AppShell** (logo/mood/nav) на calendar workspace: сегменты Обзор / Сегодня /
|
||||
Заявки + метрики/chips (не рядом с ViewMode). Soft-default по mood **без auto-pin** при
|
||||
клике на вкладку; при смене mood — `DEFAULT_LENS_BY_MOOD`. Workspace chrome: (1) селектор;
|
||||
(2) период + вид Месяц/Неделя/День; (3) owner actions — для commercial только
|
||||
«+ Новое событие» («Заполнить расписание» — во вкладке Студия). Без flip / CREATOR.
|
||||
Нет 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` —
|
||||
только просмотр; чужой `commercial` с `booking_open=true` — запись; при `booking_open=false`
|
||||
(restricted / нет подписки владельца) — просмотр + сообщение «Запись временно недоступна».
|
||||
- Выбор события открывает карточку действий: mobile — bottom sheet; desktop — боковая панель. Действия зависят от роли (owner / participant).
|
||||
- Без выбранного события — панель «О календаре» (описание, title/meta, рейтинг, отзывы).
|
||||
Форма отзыва на календарь/событие — только при confirmed booking; жалоба доступна без записи.
|
||||
На чужом календаре — CTA **«Отслеживать» / Follow** (не путать с платной «Подписка»);
|
||||
follow не открывает форму отзыва.
|
||||
- На чужом `/c/:id` — CTA «Мой календарь» и таб Calendar не `aria-current` (intent `browse`).
|
||||
— просмотр + «Запись временно недоступна». Бейдж «чужой» **не** показывать; CTA
|
||||
**«Мой календарь»** + `ChevronLeft` → `/default`.
|
||||
- **Единственный personal** (Back: title `Default`, UI «По умолчанию»): overlay confirmed
|
||||
записей на чужие commercial + specialist duty (Front#35/#43) — **только** на нём, не на
|
||||
студиях. Create calendar UI — только тип **Студия** (`commercial`). Селектор: «По умолчанию»
|
||||
+ студии.
|
||||
- **Guest на чужом commercial (D9 модель B):** roster карточек активных специалистов →
|
||||
тап → сетка только со слотами `event.specialist_id` = выбранный; «Все слоты студии»
|
||||
снимает фильтр. Это **не** chip-filter на общей сетке. Book как обычно — без
|
||||
`specialist` в body заявки (слот уже привязан к спецу на событии).
|
||||
В режиме «Все» — тот же **studio-агрегат** слотов, что у owner «Студия» (см. ниже);
|
||||
фильтр D9 **без** второго UI режима.
|
||||
- **Owner commercial workspace — режимы Студия / Мастер:**
|
||||
- **Студия** — агрегат слотов по `start_time` + `duration`: одна ячейка с заливкой
|
||||
`booked/total`, label `HH:mm · K/N свободно`; клик → popover мастеров со статусами
|
||||
(занятость по `booking_occupancy` / active bookings, BackSpec §6 events).
|
||||
- **Мастер** — фильтр сетки по `specialist_id`; sticky roster как guest D9 B
|
||||
(**owner тоже** использует roster-фильтр, не только guest).
|
||||
- **Месяц + Студия:** density markers в ячейках дня, **не** список чипов слотов.
|
||||
- **Personal:** без studio-агрегата и без вкладки «Команда» / Team.
|
||||
- ★ `0.0` на free-слотах **не** показывать.
|
||||
- Agenda / rail desktop (commercial owner, `≥ 1024px`): вкладки **Расписание | Команда | Студия** в
|
||||
общем `.eh-owner-rail-panel` **фиксированной высоты** (вкладки не прыгают). Команда:
|
||||
invite свёрнут за «Пригласить». **Студия** = hub: about + «Заполнить расписание» +
|
||||
«Редактировать» (+ link `/calendars`); Delete только на `/calendars`. Phone **и tablet**:
|
||||
segmented **День | Команда | Студия** под grid (не боковой rail — иначе колонки недели
|
||||
сжимаются). Карточка события: sheet `< 768px`, боковая панель `≥ 768px`. Списки —
|
||||
`ScrollRegion` (полоса скрыта, стрелки по краям при overflow); dialogs — thin scrollbar
|
||||
on hover.
|
||||
- Agenda empty: personal «Нет событий»; commercial «Свободных окон нет». UI-тип commercial —
|
||||
«Студия» (не «коммерческий»).
|
||||
- Выбор события открывает карточку действий: mobile — bottom sheet; desktop — боковая панель.
|
||||
- На browse `/c/:id` — CTA «Мой календарь» (+ иконка) → `/default`; tab Calendar не
|
||||
`aria-current`. Follow на чужом commercial без изменений.
|
||||
- Список отслеживаемых: `/following` (из «Ещё»).
|
||||
- Открытие события — `navigate` push (не `replace`), чтобы Back возвращал к календарю.
|
||||
- Owner: WS subscribe на активный календарь; `PUT` события из карточки; `PUT` календаря на `/calendars`.
|
||||
- Поиск (`/search`): query/filters в URL params (восстановление при возврате); chip type фильтрует
|
||||
discovery tops без ухода из Popular; в строке результата — id snippet и `calendar_title` для event.
|
||||
- CRUD своих календарей — `/calendars` (из «Ещё»). Legacy `/discover`, `/calendars/:id` → redirects.
|
||||
- На мобиле — bottom tab bar (+ safe-area); на desktop — sticky header + табы.
|
||||
- На **phone и tablet** (`< 1024px`, Tailwind `lg`) — одна primary nav: bottom tab bar
|
||||
(+ safe-area). На **desktop** (`≥ 1024px`) — **одна строка** chrome:
|
||||
logo + mood chip | nav tabs | nickname + logout (не два ряда header+nav, не bottom+top
|
||||
одновременно). Сырой email в chrome не показывать — primary identity = `nickname`
|
||||
(fallback без `@`). Mobile: logo mark-only + mood chip icon-only (Front#45);
|
||||
desktop — полный wordmark + label.
|
||||
Nav `aria-current` синхронизирован с `useLocation` (без remount `Outlet` по pathname).
|
||||
- Mood themes — см. §3.2.
|
||||
|
||||
## 3.2. Настроения (mood themes)
|
||||
## 3.2. Настроения (mood themes) / режимы CalenTIQ
|
||||
|
||||
Пользователь переключает визуальное настроение; атрибут `html[data-mood]`. Для гостя и при отсутствии `preferences.mood` — дефолт `calm` (в профиль при логине не пишется). После входа смена mood сохраняется в `preferences.mood` через `PATCH /v1/user/me`.
|
||||
Публичный бренд UI: **CalenTIQ** (см. [BRANDING.md](BRANDING.md)). Внутренние ключи API/preferences без изменений. Три mood только — **без CREATOR**, без flip календаря.
|
||||
|
||||
| Mood | Идентификатор | Характер |
|
||||
|------|---------------|----------|
|
||||
| Спокойный | `calm` | sage/mist, мягкий градиент (default) |
|
||||
| Энергичный | `energetic` | тёмный ink + coral/amber |
|
||||
| Деловой | `business` | charcoal + steel, более жёсткие радиусы |
|
||||
Пользователь переключает визуальный режим; атрибут `html[data-mood]`. Для гостя и при отсутствии `preferences.mood` — дефолт `calm` (в профиль при логине не пишется). После входа смена mood сохраняется в `preferences.mood` через `PATCH /v1/user/me`.
|
||||
|
||||
Переключатели mood и языка: на экранах auth (`/login`, `/register`, `/verify`) и в `/more`. В шапке workspace — только бренд, email и выход (без mood/lang).
|
||||
| UI-лейбл | Идентификатор (API) | Характер |
|
||||
|----------|---------------------|----------|
|
||||
| FLOW | `calm` | sage/mist, мягкий градиент (default) |
|
||||
| MOMENTUM | `energetic` | тёмный ink + coral `#FF6A3D` |
|
||||
| CONTROL | `business` | charcoal + steel `#324A5F`, более жёсткие радиусы |
|
||||
|
||||
Язык UI (`ru`/`en`): до логина — `navigator.language`; после входа — поле `language` профиля (`PATCH /v1/user/me`). Если в профиле язык пуст — при логине записывается текущий (браузерный/выбранный на экране входа). Язык также можно сменить в форме `/profile`.
|
||||
**Где mood:**
|
||||
- Auth (`/login`, `/register`, `/verify`, forgot/reset) — compact `MoodSwitcher` (+ language).
|
||||
- AppShell — primary control: **mood chip** рядом с BrandWordmark → dialog/sheet
|
||||
(**не** в `/more`, **не** в calendar toolbar Month/Week/Day). Desktop: logo + mood \| nav \| nickname + logout.
|
||||
**Front#45:** mobile (`< sm`) — полный BrandWordmark CalenTIQ + mood chip
|
||||
icon-only (`aria-label` = FLOW/MOMENTUM/CONTROL); с `sm+` — полный label chip
|
||||
без truncate. Sheet: §C glyph (sprout/bolt/briefcase) + label + tagline.
|
||||
Time Arc appicon — только favicon, не в switcher.
|
||||
|
||||
**Lens** (отдельно от mood и от view month/week/day): `overview` / `today` / `bookings`
|
||||
(UI: Обзор / Сегодня / Заявки). Инфо-табло **под AppShell** на calendar routes (не в
|
||||
toolbar ViewMode). Persist: session preference + soft LS; клик по вкладке **не** ставит
|
||||
pin навсегда; при смене mood — soft-default FLOW→overview, MOMENTUM→today,
|
||||
CONTROL→bookings. Контент: free gaps / события сегодня / upcoming bookings (Front-derive).
|
||||
|
||||
Язык UI (`ru`/`en`): до логина — `navigator.language`; после входа — поле `language` профиля (`PATCH /v1/user/me`). Если в профиле язык пуст — при логине записывается текущий (браузерный/выбранный на экране входа). Язык также в `/more` и в форме `/profile`.
|
||||
|
||||
### 3.3. Time Arc (живая сетка, Front#50)
|
||||
|
||||
На commercial week/day в режиме Студия клиент считает предложенный час без LLM и без Back: свободные слоты студии ∩ занятость пользователя (personal + confirmed bookings + **mirror** shared-personal) × mood (FLOW — воздух и середина дня, MOMENTUM — ближайший, CONTROL — доля свободных мест). Одна ячейка — `data-time-arc`; коллизии — `data-collided`. Тап — обычный Book.
|
||||
|
||||
Владелец **или** share `write|admin` (заместитель): черновики слотов из прошлой ISO-недели в дырах текущей; «Создать неделю» —
|
||||
preview, затем существующий POST events. После создания черновик снимается сразу
|
||||
(без F5), в том числе если время в форме сдвинули (прошлый час → +1 день / правка start).
|
||||
Фраза с часом уже в прошлом («11» в 14:00) ставит черновик на следующий день, тот же час.
|
||||
|
||||
**v2 (Front#51):** отказ от дуги (тап другой свободной ячейки / свайп недели) пишет штраф часу и мастеру в `localStorage` (`eh.timeArc.skips`) — дуга переезжает. ≥2 confirmed записей к одному `specialist_id` бустят его слоты. Месяц: тепло `data-time-arc-heat` на днях со скоренным часом. Personal: черновик «тот же час +7д» после прошедшего confirmed визита; тап открывает студию.
|
||||
|
||||
**v3 (дуга между календарями):** `/following` сортирует студии по ближайшему Time Arc
|
||||
(`start_time`); баннер «Ближайший час» — **min** среди подписок, без дубля `when`
|
||||
на той же строке списка. `/search`: баннер nearest — другая студия, чем первая
|
||||
карточка выдачи (если совпадает — следующий hint); Discover остаётся списком
|
||||
с `data-time-arc-when` на остальных calendar-строках. Тап открывает неделю студии
|
||||
с курсором на этом дне. Черновики владельца по активной студии. Без LLM / Back AI.
|
||||
|
||||
**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`
|
||||
- `/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`):
|
||||
- `/` — Calendar Workspace (автовыбор своего календаря или empty-state)
|
||||
- `/c/:calendarId` — workspace с календарём
|
||||
- `/c/:calendarId/e/:eventId` — workspace + карточка события
|
||||
- `/search` — поиск календарей/событий
|
||||
- `/bookings` — мои бронирования (deep-link в `/c/.../e/...`)
|
||||
- `/calendars` — управление своими календарями (CRUD)
|
||||
- `/more` — вторичное меню + mood switcher
|
||||
- `/default` — resolve единственного personal → `/c/:id` (post-login и tab «Календарь»)
|
||||
- `/default/e/:eventId` — то же + карточка события
|
||||
- `/` — alias → `/default`
|
||||
- `/bookings` — grouped inbox: (A) к подтверждению (owner/specialist pending) +
|
||||
(B) мои записи участника; deep-link в `/c/.../e/...`
|
||||
- `/calendars` — управление своими календарями (create только Студия; personal delete UI скрыт)
|
||||
- `/more` — вторичное меню + language switcher (**без** mood); пункт specialist
|
||||
invites **скрыт**, если входящих pending = 0; при pending > 0 — badge со счётчиком
|
||||
(deep-link `/invites?token=` работает)
|
||||
- `/invites` — inbox входящих specialist invites (не смешивать с `/bookings`)
|
||||
- `/following` — отслеживаемые чужие календари (follow; не платная Подписка)
|
||||
- `/reviews`, `/subscription`, `/tickets`, `/profile`
|
||||
|
||||
Redirects: `/discover` → `/search`; `/calendars/:id` → `/c/:id`; `/calendars/:id/events/:eventId` → `/c/:id/e/:eventId`.
|
||||
|
||||
Состояние вида: Zustand `calendarContextStore` (`view`, `cursorDate`, last own `calendarId` в `localStorage`).
|
||||
Состояние вида: Zustand `calendarContextStore` (`view`, `cursorDate`, last own `calendarId` в `localStorage`); lens — `lensStore` + `localStorage` (см. §3.2).
|
||||
|
||||
## 5. Модули
|
||||
|
||||
@@ -110,10 +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 — отдельно в `/more` (`preferences.mood`). Язык при первом визите — из браузера; при первом логине без `language` — пишется в профиль. Mood по умолчанию `calm`, при логине не сидится.
|
||||
`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. 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`
|
||||
@@ -122,46 +244,106 @@ Redirects: `/discover` → `/search`; `/calendars/:id` → `/c/:id`; `/calendars
|
||||
(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`.
|
||||
- Создание / апгрейд `commercial` без подписки → `402` → `/subscription`; после оплаты —
|
||||
- Прошедший месяц/неделя/день: тот же 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); после оплаты —
|
||||
возврат к созданию/редактированию
|
||||
- Владелец commercial: специалисты **через invite** (не сырой `user_id`):
|
||||
- Владелец commercial: команда — owner-строка specialist (создаётся бэком при create
|
||||
commercial) + остальные **через invite** (не сырой `user_id`):
|
||||
typeahead `GET /v1/users/lookup` и/или email → `POST …/specialist-invites`;
|
||||
список active + исходящие pending; deactivate/remove принятых.
|
||||
список + исходящие pending; deactivate/remove принятых (**не** owner).
|
||||
Выбор `specialist_id` в форме события (только `active`).
|
||||
Баннер «подписка истекла / истекает» при `booking_open=false`.
|
||||
- Invitee: inbox входящих `GET /v1/user/specialist-invites` (More / уведомления) —
|
||||
`SpecialistsPanel` (owner): во вкладке **Команда** rail (desktop) / segmented
|
||||
(mobile) — **не** внутри About; **owner всегда первым**, бейдж «Владелец»,
|
||||
галочка «Я специалист» ↔ `PUT status` active|inactive; edit name/specialization;
|
||||
Delete для owner скрыт. Mobile density — имя/теги сверху, actions снизу
|
||||
full-width (invite/deactivate не ломать); desktop — вертикальный rail + scroll.
|
||||
Режимы сетки **Студия** (агрегат `start_time`+`duration`, заливка booked/total,
|
||||
label `HH:mm · K/N свободно`, popover мастеров) и **Мастер** (фильтр
|
||||
`specialist_id` + sticky roster как D9 B) — см. §3.1. Месяц в Студии —
|
||||
density markers. Occupancy ячеек/popover — из API `booking_occupancy`
|
||||
(`"free"` | `"pending"` | `"confirmed"`, BackSpec events); ★ `0.0` на free
|
||||
не показывать. Personal — без агрегата / без вкладки Команда.
|
||||
- Guest browse commercial: roster активных → фильтр расписания по `specialist_id`
|
||||
(D9 B; см. §3.1); в «Все» — studio-агрегат как у owner; owner panel (invite/CRUD)
|
||||
не смешивать с guest roster.
|
||||
- Invitee: inbox входящих `GET /v1/user/specialist-invites` на `/invites` —
|
||||
принять / отклонить; deep-link из email → accept по `token` после логина.
|
||||
В More пункт «Приглашения» показывается **только** при `pending > 0` с badge
|
||||
счётчика (не путать с booking-заявками на `/bookings`).
|
||||
|
||||
### 5.5. События
|
||||
- Список: `GET /v1/calendars/:calendar_id/events`
|
||||
- CRUD владельца: `POST/PUT/DELETE` (в т.ч. опциональный `specialist_id`)
|
||||
- Детали: `GET /v1/events/:id` (отображение специалиста, если задан)
|
||||
- Список: `GET /v1/calendars/:calendar_id/events` — для commercial в JSON события
|
||||
опционально `booking_occupancy` (`"free"` | `"pending"` | `"confirmed"`;
|
||||
приоритет confirmed > pending > free; см. BackSpec §6). Studio-агрегат /
|
||||
popover мастеров опираются на это поле (+ capacity/bookings при необходимости).
|
||||
- CRUD владельца: `POST/PUT/DELETE` (в т.ч. опциональный `specialist_id`).
|
||||
Create-форма: **capacity = 1** (или `settings.default_capacity` студии);
|
||||
`specialist_id` подставляется из текущего roster-фильтра сетки; recurrence —
|
||||
из org defaults календаря. **WeekFill:** горизонт по умолчанию **1 неделя**;
|
||||
«Создать расписание» disabled, пока нет именованных слотов и выбранного
|
||||
специалиста; превью считает, сколько событий будет создано; пустой apply —
|
||||
один error toast, не success.
|
||||
- Детали: `GET /v1/events/:id` (отображение специалиста, если задан;
|
||||
тот же `booking_occupancy`)
|
||||
- Вхождения: `GET /v1/events/:id/occurrences`
|
||||
- Отмена вхождения: `DELETE /v1/events/:id/occurrences/:start_time`
|
||||
|
||||
### 5.6. Запись (bookings)
|
||||
- Участник: `POST /v1/events/:id/bookings` только при `booking_open`; иначе UI-сообщение /
|
||||
ответ `403`; `GET /v1/user/bookings`, `DELETE /v1/bookings/:id`, `GET /v1/bookings/:id`
|
||||
- Владелец и specialist: `GET /v1/events/:id/bookings`, `PUT /v1/bookings/:id`
|
||||
`{ action: confirm | decline }` (specialist — только свои события, см. BackSpec §2.1.2)
|
||||
- Статусы: `pending` | `confirmed` | `cancelled`; confirmation: `auto` | `manual` | timeout
|
||||
ответ `403`; `GET /v1/user/bookings`, `DELETE /v1/bookings/:id`, `GET /v1/bookings/:id`.
|
||||
Тело book **без** specialist (назначение — на `event.specialist_id`).
|
||||
- Владелец и specialist:
|
||||
- на карточке события: `GET /v1/events/:id/bookings` + Confirm/Decline (**обязательно**)
|
||||
- inbox `/bookings` группа «К подтверждению»: `GET /v1/user/booking-requests`
|
||||
(actionable pending, где user — owner календаря или `event.specialist_id`; past-pending
|
||||
Back отдаёт как `expired` и **не** включает в inbox); те же
|
||||
`PUT /v1/bookings/:id` `{ action: confirm | decline }` (на `expired` → `409`)
|
||||
- UI `/bookings`: три группы (пустые скрывать): **К подтверждению** (`booking-requests`) +
|
||||
**Записи студии** (`GET /v1/user/studio-bookings`, confirmed) + **Мои записи**
|
||||
(upcoming/past participant). `specialist_invite` сюда **не** попадает.
|
||||
Empty state: title + hint + CTA Discover / «Мои календари».
|
||||
Время слота на list-карточках — soft (secondary to title).
|
||||
- **Confirmed overlay на personal (Front-only, Front#35):** confirmed из
|
||||
`GET /v1/user/bookings` на чужих commercial — догрузить event/calendar и нарисовать
|
||||
на **своём default personal** grid; title `событие · студия · специалист`;
|
||||
клик → `/c/:foreignCalId/e/:eventId`. Back API не менялся.
|
||||
- **Specialist duty overlay на personal (Front-only, Front#43):** на своём personal —
|
||||
слоты commercial, где `event.specialist_id` = текущий user. Gate: accepted invites
|
||||
(`GET /v1/user/specialist-invites`) → unique `calendar_id` → active specialist
|
||||
(`GET /v1/calendars/:id/specialists`) → list events viewport. Title `событие · студия`;
|
||||
клик → `/c/:commercialId/e/:eventId`. Back expand virtuals без `specialist_id` —
|
||||
Front мержит masters + expand (`mergeExpandedWithMasters`) перед фильтром.
|
||||
Legacy specialist без invite-записи — вне MVP. Не зеркалить чужой roster на personal owner.
|
||||
- Статусы: `pending` | `confirmed` | `cancelled` | `expired`; confirmation: `auto` | `manual` | timeout.
|
||||
Past+pending / `expired` в «Мои записи» и на `EventActionCard` — бейдж «истекла»,
|
||||
Cancel скрыт. На past-событии Book скрыт. Defensive: если API ещё отдал past
|
||||
`pending` — UI мапит в `expired`.
|
||||
- Pending резервирует capacity (как на бэке)
|
||||
|
||||
### 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, демо-оплата (шлюз-заглушка).
|
||||
Создание commercial без подписки → `402` → `/subscription`. После renew commercial-календари
|
||||
владельца снова с `booking_open=true` без смены type (BackSpec §2.1.2 restricted → full).
|
||||
**Пилот (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).
|
||||
|
||||
### 5.10. Тикеты
|
||||
- ErrorBoundary / необработанные ошибки → `POST /v1/tickets` с `source=frontend`
|
||||
@@ -209,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
|
||||
|
||||
@@ -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 сверены с кодом.
|
||||
@@ -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 (v1–v4) | ячейки сетки |
|
||||
| **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 v1–v4 (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 v1–v4 без изменений | 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.
|
||||
@@ -3,8 +3,12 @@
|
||||
## 2. [EventHub Admin UI — Техническое задание](EventHubFrontAdminSpec.md)
|
||||
## 3. [EventHub Client UI — Техническое задание](EventHubFrontSpec.md)
|
||||
## 4. [Правила работы (workflow)](WORKFLOW.md)
|
||||
## 5. [Stage UI в Cursor Browser](STAGE-BROWSER.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)
|
||||
|
||||
@@ -1,28 +1,58 @@
|
||||
# Stage UI в Cursor Browser
|
||||
|
||||
Chromium / Cursor IDE Browser часто **не резолвят** `*.eventhub.local` (mDNS), даже если Windows/`nslookup` через OpenWRT уже видят stage.
|
||||
Stage уже доступен по **публичному DNS с валидным TLS** (Let's Encrypt). Агенты и ручной просмотр **по умолчанию** ходят **напрямую по HTTPS** — без локального `:8787` proxy.
|
||||
|
||||
Для агентов и ручного просмотра stage используем **локальный alias + HTTP-прокси**.
|
||||
Локальный alias-proxy — **только fallback**, если Cursor/Chromium не резолвит hostname stage (не из‑за сертификата).
|
||||
|
||||
## URL
|
||||
Публичные URL стенда также зафиксированы в `EventHubDevOps/docs/STANDS.md` (раздел stage).
|
||||
|
||||
## Канон (прямые HTTPS)
|
||||
|
||||
| URL | Сервис |
|
||||
|-----|--------|
|
||||
| `https://stage.calentiq.com/` | client-ui |
|
||||
| `https://admin.stage.calentiq.com/` | admin-ui |
|
||||
| `https://api.stage.calentiq.com` | eventhub API |
|
||||
| `https://ws.stage.calentiq.com` | WebSocket |
|
||||
| `https://admin-api.stage.calentiq.com` | admin API |
|
||||
| `https://calentiq.com` | 301 → `stage.calentiq.com` |
|
||||
|
||||
Для агентского Cursor Browser обычно достаточно:
|
||||
|
||||
- Client: `https://stage.calentiq.com/login`
|
||||
- Admin: `https://admin.stage.calentiq.com/`
|
||||
|
||||
Client/admin SPA ходят в API **same-origin** через nginx (`/v1/`, `/ws`).
|
||||
|
||||
Внутренний QA (`*.stage.eventhub.local`) по-прежнему есть; для браузера агента **предпочтительны** публичные `*.calentiq.com` выше.
|
||||
|
||||
## Auth / учётки
|
||||
|
||||
- Smoke: `~/.cursor/secrets/eventhub-stage.env` (`SMOKE_USER_*`).
|
||||
- IFT e2e: только `~/.cursor/secrets/eventhub-ift.env` (не подмешивать stage).
|
||||
- Не коммитить пароли/токены из smoke env.
|
||||
|
||||
## Fallback: локальный alias-proxy
|
||||
|
||||
Использовать **только** если прямое открытие `https://stage.calentiq.com/` (или admin) в Cursor Browser не работает из‑за **DNS / резолва hostname** (например Chromium и `*.local`). Не поднимать proxy «на всякий случай» и не из‑за TLS — публичный cert валиден.
|
||||
|
||||
| Открывать в браузере | Upstream |
|
||||
|----------------------|----------|
|
||||
| `http://ui.stage.eventhub.test:8787` | `https://ui.stage.eventhub.local` → `195.208.119.190` |
|
||||
| `http://ui.stage.eventhub.test:8787` | `https://ui.stage.eventhub.local` → stage IP |
|
||||
|
||||
## Одноразовая настройка hosts
|
||||
### Hosts (только для fallback)
|
||||
|
||||
В `C:\Windows\System32\drivers\etc\hosts` (или WSL `/etc/hosts` не помогает Cursor на Windows):
|
||||
В `C:\Windows\System32\drivers\etc\hosts` (WSL `/etc/hosts` не помогает Cursor на Windows):
|
||||
|
||||
```text
|
||||
127.0.0.1 ui.stage.eventhub.test
|
||||
```
|
||||
|
||||
## Прокси
|
||||
### Прокси
|
||||
|
||||
Скрипт в репозитории: [`scripts/stage-browser-alias-proxy.mjs`](scripts/stage-browser-alias-proxy.mjs)
|
||||
Скрипт: [`scripts/stage-browser-alias-proxy.mjs`](scripts/stage-browser-alias-proxy.mjs)
|
||||
|
||||
Копия для быстрого старта агента: `~/.cursor/eventhub/stage-browser-alias-proxy.mjs` (если есть — можно любой из двух).
|
||||
Копия: `~/.cursor/eventhub/stage-browser-alias-proxy.mjs` (если есть — можно любой).
|
||||
|
||||
**Важно:** процесс должен слушать на **Windows** `127.0.0.1:8787` (Node из Windows). Прокси только в WSL Cursor с Windows часто не достучится.
|
||||
|
||||
@@ -32,18 +62,15 @@ Start-Process -FilePath "C:\Program Files\nodejs\node.exe" `
|
||||
-WindowStyle Hidden
|
||||
```
|
||||
|
||||
Или из Spec (если скрипт уже скопирован в `~/.cursor/eventhub/`):
|
||||
Или из корня EventHubSpec:
|
||||
|
||||
```powershell
|
||||
# из корня EventHubSpec
|
||||
Start-Process -FilePath "C:\Program Files\nodejs\node.exe" `
|
||||
-ArgumentList (Resolve-Path ".\scripts\stage-browser-alias-proxy.mjs") `
|
||||
-WindowStyle Hidden
|
||||
```
|
||||
|
||||
Проверка: открыть `http://ui.stage.eventhub.test:8787/login`.
|
||||
|
||||
Переменные окружения (опционально):
|
||||
Проверка fallback: `http://ui.stage.eventhub.test:8787/login`.
|
||||
|
||||
| Env | Default |
|
||||
|-----|---------|
|
||||
@@ -53,16 +80,11 @@ Start-Process -FilePath "C:\Program Files\nodejs\node.exe" `
|
||||
| `STAGE_UI_HOST` | `ui.stage.eventhub.local` |
|
||||
| `ALIAS_HOST` | `ui.stage.eventhub.test` |
|
||||
|
||||
## Auth
|
||||
|
||||
Same-origin через прокси: `/v1/login`, `/v1/register`, logout — как на настоящем stage.
|
||||
|
||||
- Smoke: `~/.cursor/secrets/eventhub-stage.env` (`SMOKE_USER_*`).
|
||||
- IFT e2e: только `~/.cursor/secrets/eventhub-ift.env` (не подмешивать stage).
|
||||
- Прокси **не** подставляет пользователя: только Host/SNI rewrite на Traefik.
|
||||
Прокси **не** подставляет пользователя: только Host/SNI rewrite на Traefik.
|
||||
|
||||
## Что не делать
|
||||
|
||||
- Не ждать, что Cursor Browser сам пойдёт на `https://ui.stage.eventhub.local`.
|
||||
- Не поднимать прокси только в WSL, если агентский браузер на Windows.
|
||||
- Не считать `:8787` / `ui.stage.eventhub.test` каноном — канон это `https://stage.calentiq.com` (и admin).
|
||||
- Не поднимать proxy, пока не подтверждён сбой резолва прямого HTTPS.
|
||||
- Не поднимать proxy только в WSL, если агентский браузер на Windows.
|
||||
- Не коммитить пароли/токены из smoke env.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Stage Popular cleanup notes
|
||||
|
||||
Popular / Discover empty-search feed = ratings tops (`GET /v1/search` without `q`/filters).
|
||||
E2E и smoke иногда оставляют commercial-календари с рейтингами, из‑за чего лента засоряется.
|
||||
|
||||
## Правила
|
||||
|
||||
1. **Не** удалять «живые» smoke-календари пилота без явного фильтра.
|
||||
2. Чистить только по безопасным маркерам (название/тег/описание содержат `e2e`, `E2E`, `playwright`, `ift-litter`, или owner = известный e2e user).
|
||||
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)
|
||||
|
||||
- title/tags match: `(?i)e2e|playwright|ift-litter|tmp-`
|
||||
- created recently + zero real bookings, if policy allows
|
||||
- exclude calendars used by documented stage smoke accounts
|
||||
|
||||
## Upload / cover images
|
||||
|
||||
`image_url` на календаре отдаётся в search. **Upload API нет** — seed URL вручную или через admin/update поля. Discover показывает cover если URL непустой, иначе mood wash + initials.
|
||||
|
||||
Refs: EventHubSpec#12, EventHubBack#59, EventHubFront#30.
|
||||
@@ -72,6 +72,32 @@ Canvas: `eventhub-front-ux-review` → `eventhub-front-ux-rewalk` → **`eventhu
|
||||
- [x] Booking: sheet swipe-dismiss, `/bookings` upcoming/past, post-book CTA ([EventHubFront#23](https://git.sabilin.com/EventHub/EventHubFront/issues/23))
|
||||
- [x] Stage verify: swipe Playwright (Pixel 5), prune on host, smoke-owned litter по префиксу
|
||||
|
||||
## P2 stage journey (Front#35, Jul 2026) — shipped
|
||||
|
||||
- [x] SpecialistsPanel mobile density (имя/теги сверху, actions снизу full-width)
|
||||
- [x] F6: EventActionCard past+pending → expired, без Cancel; Book скрыт на past
|
||||
- [x] D9 модель B: guest roster → фильтр слотов по `specialist_id`; «Все слоты студии»
|
||||
- [x] Confirmed foreign bookings overlay на default personal (Front-only)
|
||||
|
||||
Трекер: [EventHubFront#35](https://git.sabilin.com/EventHub/EventHubFront/issues/35).
|
||||
|
||||
## Mood + Lens workspace (Front#39, Jul 2026) — shipped
|
||||
|
||||
- [x] Mood chip убран из Ещё; 3 moods, без flip/CREATOR (placement → AppShell в Front#40)
|
||||
- [x] Lens Обзор / Сегодня / Записи отдельно от mood и view modes
|
||||
- [x] Strip над сеткой (Front-derive); mobile collapsed; desktop expanded
|
||||
- [x] Persist: mood → `preferences.mood`; language → `user.language`; lens → localStorage
|
||||
|
||||
Трекер: [EventHubFront#39](https://git.sabilin.com/EventHub/EventHubFront/issues/39) (`c2cb754` / `d38a927`).
|
||||
|
||||
## Mood AppShell + week view (Front#40, Jul 2026) — shipped
|
||||
|
||||
- [x] Mood chip в AppShell рядом с BrandWordmark (**не** в toolbar Month/Week/Day)
|
||||
- [x] 3 moods; `PATCH preferences.mood`; без дубля в Ещё
|
||||
- [x] Week view: одна строка day-headers; scroll `.eh-cal-week-scroll` (mobile ~390)
|
||||
|
||||
Трекер: [EventHubFront#40](https://git.sabilin.com/EventHub/EventHubFront/issues/40) (mood `7c092d1`, week `db11c41`).
|
||||
|
||||
## Future (не блокер 10)
|
||||
|
||||
- [x] Swipe month/week + long-press create в календаре ([EventHubFront#24](https://git.sabilin.com/EventHub/EventHubFront/issues/24))
|
||||
|
||||
@@ -73,7 +73,7 @@ PowerShell в Cursor **ломает экранирование** (кавычки
|
||||
| Windows `npm` / `erl` / `rebar3` | npm и rebar **только в WSL** |
|
||||
| Push Front без локальной проверки | WSL: `npm run lint && npm run build && npm run test:e2e:ift`, потом push |
|
||||
|
||||
Канон для агентов: `EventHubBack/.cursor/rules/shell-wsl-not-powershell.mdc`.
|
||||
Канон для агентов: `EventHubBack/.cursor/rules/shell-wsl-not-powershell.mdc`, **`agent-pitfalls.mdc`** (типовые сбои shell/git/docker).
|
||||
|
||||
| Tool | Version | Path (WSL) |
|
||||
|------|---------|------------|
|
||||
@@ -94,6 +94,19 @@ rebar3 ct --suite=api_admins_SUITE
|
||||
|
||||
Agent: `~/.cursor/eventhub/compile-back.sh`, `run-api-tests.sh`; см. `~/.cursor/eventhub/OTP-REBAR.md`.
|
||||
|
||||
### Экономия токенов (Agent)
|
||||
|
||||
| Принцип | Как |
|
||||
|---------|-----|
|
||||
| Один чат — одна задача | Новый issue → новый чат (§5); после «готово» не продолжать тред |
|
||||
| Узкий scope в запросе | «Репо, issue, только X, не Y, done = …» |
|
||||
| Меньше tool turns | `.sh` с первого раза; не PS retry; targeted eunit/lint |
|
||||
| Subagents редко | Узкий промпт, один репо, без копии всей переписки |
|
||||
| Режим | Ask/Plan для обсуждения; Agent для кода и shell |
|
||||
| Повторяемое | Скрипты в `~/.cursor/eventhub/`, не заново в чате |
|
||||
|
||||
Канон: `EventHubBack/.cursor/rules/agent-pitfalls.mdc` §6.
|
||||
|
||||
### Git-коммиты из WSL (Agent и разработчик)
|
||||
|
||||
Коммиты из **Agent Shell / PowerShell** ломаются: Cursor подставляет `--trailer "Co-authored-by: Cursor <cursoragent@cursor.com>"`, а PowerShell падает на `<`.
|
||||
@@ -217,7 +230,7 @@ Issue: https://git.sabilin.com/EventHub/EventHubBack/issues/24
|
||||
|
||||
### Product version (`EventHubSpec/VERSION`)
|
||||
|
||||
Единый источник правды: **`EventHubSpec/VERSION`** — формат **`MAJOR.MINOR`** (сейчас `0.1`).
|
||||
Единый источник правды: **`EventHubSpec/VERSION`** — формат **`MAJOR.MINOR`** (сейчас `0.2`).
|
||||
|
||||
- Бампается **вручную и редко** (смена продуктовой линейки).
|
||||
- В `EventHubBack` / `EventHubFrontAdmin` / `EventHubFront` файл `VERSION` — только **fallback** для локальных сборок без доступа к Spec.
|
||||
@@ -240,7 +253,7 @@ Issue: https://git.sabilin.com/EventHub/EventHubBack/issues/24
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "eventhub",
|
||||
"version": "0.1",
|
||||
"version": "0.2",
|
||||
"build": 338,
|
||||
"git_sha": "2b65a804d354",
|
||||
"built_at": "2026-07-17T18:00:00Z"
|
||||
@@ -253,8 +266,8 @@ Issue: https://git.sabilin.com/EventHub/EventHubBack/issues/24
|
||||
|
||||
Отображение:
|
||||
|
||||
- Admin: `Admin UI 0.1.341 (eb37476)` / `API 0.1.338 (2b65a804d354)`
|
||||
- Client: `UI 0.1.{build} (sha)` / `API 0.1.{build} (sha)`
|
||||
- Admin: `Admin UI 0.2.{build} (sha)` / `API 0.2.{build} (sha)`
|
||||
- Client: `UI 0.2.{build} (sha)` / `API 0.2.{build} (sha)`
|
||||
|
||||
Третье число в лейбле — **build**, не semver-patch релиза.
|
||||
|
||||
@@ -275,11 +288,12 @@ IFT и stage крутят **одну и ту же** сборку `sha-*`. Produc
|
||||
|
||||
### Stage UI в Cursor Browser
|
||||
|
||||
`*.eventhub.local` часто не резолвится в Chromium/Cursor IDE Browser (mDNS). Для ручного и агентского просмотра stage:
|
||||
Stage: публичный DNS + валидный TLS. **Канон — прямой HTTPS** (не локальный `:8787`):
|
||||
|
||||
- URL: `http://ui.stage.eventhub.test:8787`
|
||||
- Инструкция + proxy: **`STAGE-BROWSER.md`**, скрипт `scripts/stage-browser-alias-proxy.mjs` (Windows Node на `:8787`)
|
||||
- Hosts: `127.0.0.1 ui.stage.eventhub.test`
|
||||
- Client: `https://stage.calentiq.com/`
|
||||
- Admin: `https://admin.stage.calentiq.com/`
|
||||
- Инструкция: **`STAGE-BROWSER.md`**
|
||||
- Proxy `ui.stage.eventhub.test:8787` — **только fallback** при сбое резолва hostname в Cursor/Chromium
|
||||
- Учётки smoke: `~/.cursor/secrets/eventhub-stage.env` (не коммитить)
|
||||
|
||||
---
|
||||
@@ -361,6 +375,9 @@ error rate > 1% **или** mean request > 500 ms (hold ≥2 мин в фазе)
|
||||
- После успешных тестов — обновить спеку, если менялся внешний контракт.
|
||||
- Одна задача — один логический объём работы.
|
||||
- Код фичи — в репозитории-владельце; `EventHubSpec` — для документации и процесса.
|
||||
- **Один агент — один репо** (не смешивать Front/Back/Spec/FrontAdmin в одной сессии): `.cursor/rules/one-repo-per-agent.mdc`.
|
||||
- **Типовые ошибки агентов** (PS, git clean, docker scale, scope): `EventHubBack/.cursor/rules/agent-pitfalls.mdc`; в промпт субагента включать shell + pitfalls.
|
||||
- **Экономия токенов:** один чат — одна задача; узкий scope; subagents редко; Ask vs Agent — см. `agent-pitfalls.mdc` §6 и §2.1 выше.
|
||||
- Метки во всех репозиториях держать одинаковыми (`sync-labels.ps1`).
|
||||
- Секреты и токены не коммитить; Gitea token — только в env / `~/.cursor/secrets/`.
|
||||
- **Git commit/push — через WSL** (`~/.cursor/eventhub/git-commit.sh`, `git-push.sh`); **push — только после подтверждения пользователя** (см. §2.1).
|
||||
@@ -376,6 +393,16 @@ error rate > 1% **или** mean request > 500 ms (hold ≥2 мин в фазе)
|
||||
|
||||
| Дата | Изменение |
|
||||
|------|-----------|
|
||||
| 2026-07-29 | Spec sync Front#43: specialist duty overlay на personal (accepted invites + active check) |
|
||||
| 2026-07-28 | Spec sync Front#40: Mood chip в AppShell у BrandWordmark; week single day-header + `.eh-cal-week-scroll` |
|
||||
| 2026-07-28 | Spec sync Front#39: Mood chip в workspace (не Ещё); Lens Обзор/Сегодня/Записи + strip; mood→DB, lens→localStorage |
|
||||
| 2026-07-28 | Spec sync Front#35: D9 B guest specialist schedule; confirmed overlay на personal; SpecialistsPanel density; F6 EventActionCard |
|
||||
| 2026-07-27 | Spec sync Back#61: `specialist_id` на POST event; commercial без active sub → 402; trial только `start_trial` |
|
||||
| 2026-07-27 | `agent-pitfalls.mdc` §6: экономия токенов (чат, scope, subagents, режимы) |
|
||||
| 2026-07-27 | `agent-pitfalls.mdc`: типовые сбои PS/git/docker/scope; промпт субагентам |
|
||||
| 2026-07-27 | Spec sync: booking `expired` (Back#60) + Front#31 UX (desktop chrome, empty bookings, agenda copy, Discover covers, nickname, invites badge) |
|
||||
| 2026-07-27 | Spec#12: booking-requests + Bookings IA + image_url; правило one-repo-per-agent |
|
||||
| 2026-07-27 | §6 / STAGE-BROWSER: канон — прямой HTTPS `*.calentiq.com`; proxy `:8787` только DNS-fallback |
|
||||
| 2026-07-22 | §6: Stage UI в Cursor Browser — `STAGE-BROWSER.md` + alias proxy |
|
||||
| 2026-07-22 | §2.1: Agent shell — не PowerShell для сложного; `.sh` + WSL; Front push только после lint/build/IFT |
|
||||
| 2026-07-15 | §7: анализ через EventHub `/metrics` + admin `nodes/metrics`; compose Prom опционален |
|
||||
|
||||
@@ -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 A–B–C, 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 (не трогать соседний репо «заодно»).
|
||||
@@ -0,0 +1,8 @@
|
||||
# Design artifacts
|
||||
|
||||
| Папка | Назначение |
|
||||
|-------|------------|
|
||||
| [`front/`](front/) | Клиентский EventHubFront / бренд CalenTIQ |
|
||||
| [`admin/`](admin/) | EventHubFrontAdmin (позже) |
|
||||
|
||||
См. также [`BRANDING.md`](../BRANDING.md).
|
||||
@@ -0,0 +1,5 @@
|
||||
# EventHubFrontAdmin — дизайн
|
||||
|
||||
Зарезервировано под утверждённые мокапы / борды админки.
|
||||
|
||||
Пока пусто: клиентский фронт — в [`../front/`](../front/).
|
||||
@@ -0,0 +1,55 @@
|
||||
# CalenTIQ / EventHubFront — дизайн (SoT)
|
||||
|
||||
Канонические артефакты клиентского фронта (EventHubFront). Публичный бренд: **CalenTIQ**.
|
||||
Правила и решения: [`BRANDING.md`](../../BRANDING.md).
|
||||
План visual parity (mood-first): [`UI-PARITY.md`](UI-PARITY.md).
|
||||
Админские мокапы — отдельно в [`../admin/`](../admin/).
|
||||
|
||||
## Moods
|
||||
|
||||
| API key | UI-лейбл | Акцент |
|
||||
|---------|----------|--------|
|
||||
| `calm` | **FLOW** | sage `#ABC5B5` / primary `#2F7A68` |
|
||||
| `energetic` | **MOMENTUM** | coral `#FF6A3D` на ink `#111820` |
|
||||
| `business` | **CONTROL** | steel `#324A5F` |
|
||||
|
||||
Токены: [`tokens/colors.json`](tokens/colors.json), [`tokens/typography.json`](tokens/typography.json) (Syne / Manrope).
|
||||
|
||||
## IA (ограничения)
|
||||
|
||||
- Top nav (desktop) + bottom nav (mobile)
|
||||
- **Без left sidebar**
|
||||
- Без 4-го Creator mood
|
||||
- Внутренние репо (`EventHub*`) не переименовываем
|
||||
|
||||
## Структура
|
||||
|
||||
### `boards/` — UI-борды
|
||||
|
||||
| Файл | Что это |
|
||||
|------|---------|
|
||||
| `calentiq-ui-board-final.png` | Утверждённый UI-борд направления CalenTIQ (Time Arc, moods) |
|
||||
| `calentiq-icon-system-final.png` | Утверждённый борда иконок / app icons по moods |
|
||||
| `eventhub-stage-ui-as-is.png` | As-is stage UI (референс текущего продукта) |
|
||||
| `eventhub-discover-*.png` | As-is Discover по moods (flow / momentum / control) + as-is |
|
||||
|
||||
### `logo/` — Time Arc
|
||||
|
||||
- `mark.svg` (+ `mark-black.svg`, `mark-white.svg`) — master mark
|
||||
- `lockup-horizontal.svg` (+ black/white) — wordmark lockup
|
||||
- `mark-1024.png`, `lockup-horizontal.png` — растровые превью
|
||||
|
||||
### `mood-icons/`
|
||||
|
||||
- `appicon-{flow,momentum,control}.svg` — master mood icons
|
||||
- `appicon-*-1024.png` — растры для сторов / превью
|
||||
|
||||
## Runtime-копии
|
||||
|
||||
В приложении те же векторы живут в `EventHubFront/public/brand/`.
|
||||
Эта папка Spec — **дизайн SoT** для согласования; runtime Front может отставать или опережать до синка.
|
||||
|
||||
## Источник
|
||||
|
||||
Собрано из утверждённого local brand pack (`calentiq-brand-pack`) и Desktop as-is скринов.
|
||||
ZIP-архивы и GPT-stub дубликаты в Spec не кладём.
|
||||
@@ -0,0 +1,264 @@
|
||||
# UI parity: EventHubFront ↔ CalenTIQ board
|
||||
|
||||
План визуального выравнивания клиентского фронта с утверждённым бордом.
|
||||
SoT: [`boards/calentiq-ui-board-final.png`](boards/calentiq-ui-board-final.png).
|
||||
Эпик: [EventHubSpec#10](https://git.sabilin.com/EventHub/EventHubSpec/issues/10).
|
||||
|
||||
**Статус:** workstreams 1–5 закрыты в коде Front (Refs Front#25–#29). Stage smoke на `https://stage.calentiq.com` — **done** (UI `0.2.29` / `79dadfb21f98`, API `0.2.158`).
|
||||
|
||||
---
|
||||
|
||||
## Known deviations from board (Front#29)
|
||||
|
||||
Зафиксировано осознанно / пока не делаем (после visual pass ×3 moods + stage):
|
||||
|
||||
| Зона | Борд | Сейчас |
|
||||
|------|------|--------|
|
||||
| Pixel-perfect calendar | denser board mock + Today/Upcoming rail | Agenda rail (§B): title-case Сегодня/Предстоящие + count, time-first rows; month chips/cell wash (§B/D) по mood; не 1:1 Figma |
|
||||
| Discover cards | soft cards + media thumb + CTA | mood media plane (wash/initials + `image_url` when present) + soft-card rhythm + CTA; без full photo-hero grid |
|
||||
| CONTROL booking §E | отдельные mock-карточки «слот/специалист» | `EventActionCard` / specialists / bookings: steel rail (усилен), avatar, available label, Confirm/Decline; не pixel-perfect board mocks |
|
||||
| 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#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 «Свободных окон нет» |
|
||||
| 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; в «Все» — 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); 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) |
|
||||
|
||||
IA без sidebar / без Creator mood — совпадает с non-goals (проверено на stage).
|
||||
|
||||
### Visual pass ×3 moods (local + stage)
|
||||
|
||||
**Checked (vite preview + Playwright shots + stage HTTPS):** Login §D, Calendar grid+agenda, Discover media, CONTROL slot/bookings (Confirm/Decline на event card), More mood cards — FLOW / MOMENTUM / CONTROL.
|
||||
|
||||
**Polish in this pass:** agenda copy/casing/counts + time-first rows; CONTROL steel rail emphasis; neutral Discover cover in mock (не flow-appicon во всех moods).
|
||||
|
||||
**Front#31 (2026-07-27):** desktop one-row chrome (logo|nav|logout); header nickname; bookings `expired` + soft list time + empty CTA; agenda personal vs commercial empty copy; Discover cover photo/fallback; More invites badge.
|
||||
|
||||
**Front#35 (2026-07-28, SHA `57cfcf3`):** D9 модель B (guest roster → schedule filter); confirmed foreign overlay на personal; SpecialistsPanel mobile density; F6 EventActionCard past+pending → expired / no Cancel, Book скрыт на past.
|
||||
|
||||
**Front#39 (2026-07-28, SHA `c2cb754` / `d38a927`):** Mood chip убран из Ещё; Lens Обзор/Сегодня/Записи + strip над сеткой во всех 3 moods; mobile sheets + strip collapsed; mood→DB, lens→localStorage; без flip / CREATOR. (Placement chip уточнён в Front#40.) **Controls IA (2026-07-29):** lens перенесён в шапку strip (labels: Обзор / На сегодня / Заявки); view рядом с периодом; owner schedule actions отдельным рядом.
|
||||
|
||||
**Front#40 (2026-07-28, SHA mood `7c092d1` / week `db11c41`):** Mood chip в AppShell рядом с BrandWordmark (**не** в calendar toolbar Month/Week/Day); 3 moods; `PATCH preferences.mood`; без дубля в Ещё. Week view: одна строка day-headers + `.eh-cal-week-scroll` для mobile ~390.
|
||||
|
||||
**Front#45 (2026-07-29):** Mobile mood chrome — chip icon-only (без truncate label); полный BrandWordmark CalenTIQ на mobile; sheet icon+label+tagline; `aria-label` на chip. Switcher glyphs по board §C (sprout/bolt/briefcase), не Time Arc appicon.
|
||||
|
||||
**Remaining intentional:** no pixel-perfect phone chrome; Discover без photo-hero grid (cover via `image_url` when API returns it; broken URL → wash+initials); §E specialist schedule card vs panel — не pixel-perfect board mocks (follow-up). Product flow D9 B — documented; board pixel mock — Future.
|
||||
|
||||
### Bookings inbox + More invites (locked 2026-07-27)
|
||||
|
||||
Продуктовые решения (не deviation — канон):
|
||||
|
||||
1. **Event card Confirm/Decline** остаётся обязательным (quick approve на слоте).
|
||||
2. **`/bookings`** — grouped inbox, не только participant:
|
||||
- **Группа A — К подтверждению:** pending bookings, где пользователь calendar **owner** или **assigned specialist** (`event.specialist_id` = user). Confirm/Decline те же, что на `EventActionCard`.
|
||||
- **Группа B — Мои записи:** participant bookings (upcoming/past + Cancel) как раньше.
|
||||
- Пустые группы скрывать (`count=0`).
|
||||
- Опционально подпись owner vs specialist внутри A; иначе одна секция «К подтверждению».
|
||||
3. **`specialist_invite` в More:** скрывать пункт меню, если **0 pending** входящих; при
|
||||
pending > 0 — **badge** со счётчиком. Deep-link `/invites?token=` работает. Исходящие
|
||||
invite остаются в `SpecialistsPanel`.
|
||||
4. **Не** класть строки `specialist_invite` в `/bookings`.
|
||||
5. **Past-pending / `expired`:** Back#60 помечает past-pending → `expired` и исключает из
|
||||
`booking-requests`; Confirm/Decline → `409`. UI: бейдж «истекла», Cancel скрыт
|
||||
(Bookings + EventActionCard); defensive map past+pending → expired badge.
|
||||
Front#35 F6: на past Book скрыт; past+pending на карточке → expired label.
|
||||
|
||||
API: `GET /v1/user/booking-requests` (Back) + существующий `PUT /v1/bookings/:id`
|
||||
(`409` на expired / full).
|
||||
|
||||
### Guest specialist schedule + confirmed overlay (locked Front#35)
|
||||
|
||||
1. **D9 модель B (guest на commercial):** roster активных специалистов (карточки) →
|
||||
тап → сетка только со слотами выбранного `event.specialist_id`; CTA «Все слоты
|
||||
студии» сбрасывает фильтр. **Не** chip-filter на shared grid. Book body без
|
||||
specialist. Owner `SpecialistsPanel` (invite/CRUD) не менять этим флоу.
|
||||
2. **Confirmed на personal (Front-only):** `GET /v1/user/bookings` (confirmed) +
|
||||
догрузка events/calendars → overlay на **default personal** grid; title
|
||||
`event · studio · specialist`; клик → `/c/:foreignCalId/e/:eventId`. Back без
|
||||
изменений.
|
||||
3. **SpecialistsPanel density (mobile):** имя/теги сверху, actions снизу full-width.
|
||||
|
||||
### 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.
|
||||
**Нет** в `/more`; **не** в toolbar Month/Week/Day. Persist: `preferences.mood`
|
||||
через `PATCH /v1/user/me`. Без flip; без CREATOR. Front#45: mobile chip icon-only;
|
||||
полный 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: Обзор / Сегодня / Заявки. **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).
|
||||
|
||||
### Follow-ups (вне этого fix-pass или частично)
|
||||
|
||||
| Тема | Статус |
|
||||
|------|--------|
|
||||
| Discover `image_url` в search serialization | Back отдаёт поле; upload API **нет** — только URL field / seed; Front#31: photo vs fallback media classes + onError fallback |
|
||||
| Stage Popular clutter (e2e calendars) | filter notes: см. `EventHubSpec/STAGE-POPULAR-CLEANUP.md`; не трогать реальные smoke данные без фильтра по e2e-паттернам |
|
||||
| §E specialist schedule card vs panel | deviation: EventActionCard + SpecialistsPanel; pixel mock — Future; **product D9 B** — done Front#35 |
|
||||
| Desktop one-row chrome | **done** Front#31; mood chip у logo — Front#40 (`7c092d1`) |
|
||||
| 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
|
||||
|
||||
Mood — не переименование `calm|energetic|business` и не смена primary hex.
|
||||
|
||||
Каждый mood — **отдельная атмосфера продукта** при той же IA:
|
||||
|
||||
| Mood | Ощущение | Chrome / surfaces | Discover / booking feel |
|
||||
|------|----------|-------------------|-------------------------|
|
||||
| **FLOW** (`calm`) | спокойствие, баланс | soft sage panels, light wash, soft chips | мягкие карточки, спокойный CTA |
|
||||
| **MOMENTUM** (`energetic`) | энергия, скорость | ink `#111820`, coral `#FF6A3D`, denser chips | тёмный discover, яркий CTA |
|
||||
| **CONTROL** (`business`) | бизнес, доступность | steel `#324A5F`, cool neutrals | коммерческие карточки слот/специалист/CTA |
|
||||
|
||||
Референс на борде: секции **B Mood Overview**, **C Mood Switcher**, **D Core Screens**, **NEW Discover**, **E Booking / Commercial**.
|
||||
|
||||
Switcher — **branded cards** (иконка + короткий tagline), не plain list/tabs:
|
||||
|
||||
- FLOW — *Find balance*
|
||||
- MOMENTUM — *Get more done*
|
||||
- CONTROL — *Grow the business*
|
||||
|
||||
Опционально: mood-aware favicon из [`boards/calentiq-icon-system-final.png`](boards/calentiq-icon-system-final.png) / `mood-icons/`.
|
||||
|
||||
Токены сейчас узкие ([`tokens/colors.json`](tokens/colors.json)); для parity нужны surfaces, gradients, chip/event styles, chrome — не только primary.
|
||||
|
||||
---
|
||||
|
||||
## Workstreams (mood-first)
|
||||
|
||||
Порядок важен: сначала атмосфера и токены, затем экраны поверх них.
|
||||
|
||||
### 1. Mood system / atmosphere — **P0 / first** ✅
|
||||
|
||||
CSS-токены и chrome на mood beyond primary hex: surfaces, gradients, event chip styles; switcher как branded cards (icon + tagline); опционально mood favicon.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- [x] Per-mood token set: surface / wash / chip / border / CTA (не только primary)
|
||||
- [x] Mood switcher = 3 branded cards с иконкой и tagline с борда
|
||||
- [x] Смена mood меняет атмосферу chrome (не только accent button)
|
||||
- [x] API keys остаются `calm|energetic|business`
|
||||
- [x] Опционально: favicon следует активному mood
|
||||
|
||||
**Design:** board §B, §C, §F; `mood-icons/`; `tokens/`
|
||||
**Done:** EventHubFront#25 — `index.css` mood tokens + `MoodSwitcher` cards + favicon via `applyMood`.
|
||||
|
||||
### 2. Calendar mood-chrome — **P0** ✅
|
||||
|
||||
Та же сетка/IA; soft panels (FLOW), denser chips (MOMENTUM), mood-tinted cells (CONTROL) — как на борде §B / §D Calendar.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- [x] Month/week выглядят различно по mood при той же навигации
|
||||
- [x] Event chips / cells тонированы mood-токенами
|
||||
- [x] Нет sidebar / смены layout ради «красоты»
|
||||
|
||||
**Design:** board §B Mood Overview, §D Calendar
|
||||
**Done:** EventHubFront#26 — `--cal-*` tokens + `.eh-cal` / `.eh-chip` в `CalendarWidget`; IA без изменений.
|
||||
**Follow-up:** month grid §B/D — FLOW sage cell wash + chip limit 2; MOMENTUM denser/hotter chips limit 5; CONTROL steel cells + sharper chips limit 3; split time/title chips.
|
||||
|
||||
### 3. Discover mood variants — **P1** ✅
|
||||
|
||||
Визуальная дифференциация FLOW vs MOMENTUM vs CONTROL: soft cards + CTA; соответствие борду (NEW Discover / §D Discover), не as-is скринам.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- [x] Discover в трёх moods визуально различим (фон, карточки, CTA)
|
||||
- [x] Soft card + CTA pattern с борда (не плоский as-is list-only)
|
||||
- [x] IA и фильтры без смены модели; только presentation
|
||||
|
||||
**Design:** board NEW Discover, §D Discover; as-is refs: `eventhub-discover-{flow,momentum,control}.png`
|
||||
**Done:** EventHubFront#27 — `--disc-*` + `.eh-discover-card` / `.eh-discover-cta`; list → card grid.
|
||||
**Follow-up:** media hierarchy — `.eh-discover-media` with per-mood size/radius/washes (FLOW air / MOMENTUM energy / CONTROL steel), optional cover via `image_url`, display title + soft-card rhythm.
|
||||
|
||||
### 4. CONTROL booking surfaces — **P1** ✅
|
||||
|
||||
Коммерческие карточки: слот, специалист, CTA — только Control-specific (§E).
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- [x] Slot card + specialist card + primary CTA в CONTROL
|
||||
- [x] Specialists остаются внутри Control (не 4-й mood)
|
||||
- [x] FLOW/MOMENTUM не получают Control commercial chrome
|
||||
|
||||
**Design:** board §E Booking / Commercial
|
||||
**Done:** EventHubFront#28 — `.eh-book-slot` / `.eh-book-specialist` / `.eh-book-request`; steel chrome только под `data-mood=business`.
|
||||
**Follow-up:** §E polish — `--book-*` tokens; CONTROL steel left-rail + sharper radii; slot duration/available label; specialist/request avatars; mood-token CTA/Decline.
|
||||
|
||||
### 5. Polish + stage smoke — **P2** ✅
|
||||
|
||||
Мелкие расхождения с бордом, wordmark/lockup consistency, stage smoke трёх moods.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- [x] Mock smoke: calendar → discover → bookings в 3 moods (+ IA без sidebar)
|
||||
- [x] Нет регрессий IA (top + bottom nav) — покрыто mock e2e
|
||||
- [x] Известные отклонения от борда зафиксированы в UI-PARITY (Known deviations)
|
||||
- [x] Visual pass ×3 moods (local preview / Playwright shots) — Login / Calendar / Discover / CONTROL booking
|
||||
- [x] Stage smoke на живом стенде — UI `0.2.29` (`79dadfb21f98`) @ `https://stage.calentiq.com` (2026-07-27)
|
||||
|
||||
**Done:** EventHubFront#29 — `e2e/tests/mood-parity-smoke.spec.ts`, brand meta, CONTROL tagline polish.
|
||||
**Follow-up:** visual pass polish — agenda Предстоящие + counts/time-first; CONTROL steel rail; mock Discover cover neutral. Stage: seed commercial + slots + pending booking для §E Confirm/Decline.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Left sidebar / смена IA
|
||||
- 4-й Creator mood / AI-assistant home
|
||||
- Rename API keys (`calm|energetic|business` остаются)
|
||||
- Покупка доменов / DNS cutover
|
||||
- Админский UI (`design/admin/`) — не трогать Front-бордами
|
||||
- Полный pixel-perfect каждого пикселя борда (направление + mood atmosphere)
|
||||
|
||||
---
|
||||
|
||||
## Mapping → issues
|
||||
|
||||
| # | Workstream | Issue |
|
||||
|---|------------|-------|
|
||||
| — | Story (родитель) | [Spec#11](https://git.sabilin.com/EventHub/EventHubSpec/issues/11) |
|
||||
| 1 | Mood system / atmosphere | [Front#25](https://git.sabilin.com/EventHub/EventHubFront/issues/25) |
|
||||
| 2 | Calendar mood-chrome | [Front#26](https://git.sabilin.com/EventHub/EventHubFront/issues/26) |
|
||||
| 3 | Discover mood variants | [Front#27](https://git.sabilin.com/EventHub/EventHubFront/issues/27) |
|
||||
| 4 | CONTROL booking surfaces | [Front#28](https://git.sabilin.com/EventHub/EventHubFront/issues/28) |
|
||||
| 5 | Polish + stage smoke | [Front#29](https://git.sabilin.com/EventHub/EventHubFront/issues/29) |
|
||||
| — | P1/P2 UX polish + expired + desktop chrome | [Front#31](https://git.sabilin.com/EventHub/EventHubFront/issues/31) |
|
||||
| — | Past-pending → `expired` | [Back#60](https://git.sabilin.com/EventHub/EventHubBack/issues/60) |
|
||||
| — | P2: D9 B + confirmed personal + density/F6 | [Front#35](https://git.sabilin.com/EventHub/EventHubFront/issues/35) |
|
||||
| — | Mood chip + Lens strip (workspace) | [Front#39](https://git.sabilin.com/EventHub/EventHubFront/issues/39) |
|
||||
| — | Mood chip → AppShell + week scroll | [Front#40](https://git.sabilin.com/EventHub/EventHubFront/issues/40) |
|
||||
| — | Mood chip icon-only mobile + mark-only logo | [Front#45](https://git.sabilin.com/EventHub/EventHubFront/issues/45) |
|
||||
|
||||
Эпик: [Spec#10](https://git.sabilin.com/EventHub/EventHubSpec/issues/10).
|
||||
|
||||
---
|
||||
|
||||
## Suggested start
|
||||
|
||||
Кодить с **Task 1 (Mood system / atmosphere)**: без токенов и switcher-as-cards остальные экраны будут «перекраской primary», а не mood product surface.
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 253 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 420" fill="none">
|
||||
<g transform="translate(20,8) scale(0.38)">
|
||||
<g stroke="#14233A" stroke-width="48" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M512 132 A380 380 0 1 0 512 892"/>
|
||||
<path d="M512 512 L377.77 434.50"/>
|
||||
<path d="M512 512 L698.20 404.50"/>
|
||||
</g>
|
||||
<circle cx="512" cy="512" r="24" fill="#14233A"/>
|
||||
<circle cx="816.06" cy="207.94" r="18" fill="#14233A"/>
|
||||
<circle cx="884.39" cy="297.00" r="18" fill="#14233A"/>
|
||||
<circle cx="927.35" cy="400.71" r="18" fill="#14233A"/>
|
||||
<circle cx="942.00" cy="512.00" r="18" fill="#14233A"/>
|
||||
<circle cx="927.35" cy="623.29" r="18" fill="#14233A"/>
|
||||
<circle cx="884.39" cy="727.00" r="18" fill="#14233A"/>
|
||||
<circle cx="816.06" cy="816.06" r="18" fill="#14233A"/>
|
||||
</g>
|
||||
<text x="430" y="270" font-family="Syne, Montserrat, Arial Black, sans-serif" font-size="168" font-weight="800" fill="#14233A">Calen<tspan fill="#4C8E7A">TIQ</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 420" fill="none">
|
||||
<g transform="translate(20,8) scale(0.38)">
|
||||
<g stroke="#FFFFFF" stroke-width="48" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M512 132 A380 380 0 1 0 512 892"/>
|
||||
<path d="M512 512 L377.77 434.50"/>
|
||||
<path d="M512 512 L698.20 404.50"/>
|
||||
</g>
|
||||
<circle cx="512" cy="512" r="24" fill="#FFFFFF"/>
|
||||
<circle cx="816.06" cy="207.94" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="884.39" cy="297.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="927.35" cy="400.71" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="942.00" cy="512.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="927.35" cy="623.29" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="884.39" cy="727.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="816.06" cy="816.06" r="18" fill="#FFFFFF"/>
|
||||
</g>
|
||||
<text x="430" y="270" font-family="Syne, Montserrat, Arial Black, sans-serif" font-size="168" font-weight="800" fill="#FFFFFF">Calen<tspan fill="#ABC5B5">TIQ</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 420" fill="none">
|
||||
<g transform="translate(20,8) scale(0.38)">
|
||||
<g stroke="#14233A" stroke-width="48" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M512 132 A380 380 0 1 0 512 892"/>
|
||||
<path d="M512 512 L377.77 434.50"/>
|
||||
<path d="M512 512 L698.20 404.50"/>
|
||||
</g>
|
||||
<circle cx="512" cy="512" r="24" fill="#14233A"/>
|
||||
<circle cx="816.06" cy="207.94" r="18" fill="#14233A"/>
|
||||
<circle cx="884.39" cy="297.00" r="18" fill="#14233A"/>
|
||||
<circle cx="927.35" cy="400.71" r="18" fill="#14233A"/>
|
||||
<circle cx="942.00" cy="512.00" r="18" fill="#14233A"/>
|
||||
<circle cx="927.35" cy="623.29" r="18" fill="#14233A"/>
|
||||
<circle cx="884.39" cy="727.00" r="18" fill="#14233A"/>
|
||||
<circle cx="816.06" cy="816.06" r="18" fill="#14233A"/>
|
||||
</g>
|
||||
<text x="430" y="270" font-family="Syne, Montserrat, Arial Black, sans-serif" font-size="168" font-weight="800" fill="#14233A">Calen<tspan fill="#4C8E7A">TIQ</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
|
||||
<g stroke="#000000" stroke-width="48" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M512 132 A380 380 0 1 0 512 892"/>
|
||||
<path d="M512 512 L377.77 434.50"/>
|
||||
<path d="M512 512 L698.20 404.50"/>
|
||||
</g>
|
||||
<circle cx="512" cy="512" r="24" fill="#000000"/>
|
||||
<circle cx="816.06" cy="207.94" r="18" fill="#000000"/>
|
||||
<circle cx="884.39" cy="297.00" r="18" fill="#000000"/>
|
||||
<circle cx="927.35" cy="400.71" r="18" fill="#000000"/>
|
||||
<circle cx="942.00" cy="512.00" r="18" fill="#000000"/>
|
||||
<circle cx="927.35" cy="623.29" r="18" fill="#000000"/>
|
||||
<circle cx="884.39" cy="727.00" r="18" fill="#000000"/>
|
||||
<circle cx="816.06" cy="816.06" r="18" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 765 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
|
||||
<g stroke="#FFFFFF" stroke-width="48" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M512 132 A380 380 0 1 0 512 892"/>
|
||||
<path d="M512 512 L377.77 434.50"/>
|
||||
<path d="M512 512 L698.20 404.50"/>
|
||||
</g>
|
||||
<circle cx="512" cy="512" r="24" fill="#FFFFFF"/>
|
||||
<circle cx="816.06" cy="207.94" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="884.39" cy="297.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="927.35" cy="400.71" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="942.00" cy="512.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="927.35" cy="623.29" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="884.39" cy="727.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="816.06" cy="816.06" r="18" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 765 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none">
|
||||
<g stroke="#000000" stroke-width="48" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M512 132 A380 380 0 1 0 512 892"/>
|
||||
<path d="M512 512 L377.77 434.50"/>
|
||||
<path d="M512 512 L698.20 404.50"/>
|
||||
</g>
|
||||
<circle cx="512" cy="512" r="24" fill="#000000"/>
|
||||
<circle cx="816.06" cy="207.94" r="18" fill="#000000"/>
|
||||
<circle cx="884.39" cy="297.00" r="18" fill="#000000"/>
|
||||
<circle cx="927.35" cy="400.71" r="18" fill="#000000"/>
|
||||
<circle cx="942.00" cy="512.00" r="18" fill="#000000"/>
|
||||
<circle cx="927.35" cy="623.29" r="18" fill="#000000"/>
|
||||
<circle cx="884.39" cy="727.00" r="18" fill="#000000"/>
|
||||
<circle cx="816.06" cy="816.06" r="18" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 765 B |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" fill="none">
|
||||
<rect width="1024" height="1024" rx="228" fill="#324A5F"/>
|
||||
<g stroke="#FFFFFF" stroke-width="48" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M512 132 A380 380 0 1 0 512 892"/>
|
||||
<path d="M512 512 L377.77 434.50"/>
|
||||
<path d="M512 512 L698.20 404.50"/>
|
||||
</g>
|
||||
<circle cx="512" cy="512" r="24" fill="#FFFFFF"/>
|
||||
<circle cx="816.06" cy="207.94" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="884.39" cy="297.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="927.35" cy="400.71" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="942.00" cy="512.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="927.35" cy="623.29" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="884.39" cy="727.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="816.06" cy="816.06" r="18" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 865 B |
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" fill="none">
|
||||
<rect width="1024" height="1024" rx="228" fill="#ABC5B5"/>
|
||||
<g stroke="#FFFFFF" stroke-width="48" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M512 132 A380 380 0 1 0 512 892"/>
|
||||
<path d="M512 512 L377.77 434.50"/>
|
||||
<path d="M512 512 L698.20 404.50"/>
|
||||
</g>
|
||||
<circle cx="512" cy="512" r="24" fill="#FFFFFF"/>
|
||||
<circle cx="816.06" cy="207.94" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="884.39" cy="297.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="927.35" cy="400.71" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="942.00" cy="512.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="927.35" cy="623.29" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="884.39" cy="727.00" r="18" fill="#FFFFFF"/>
|
||||
<circle cx="816.06" cy="816.06" r="18" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 865 B |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" fill="none">
|
||||
<rect width="1024" height="1024" rx="228" fill="#111820"/>
|
||||
<g stroke="#FFFFFF" stroke-width="48" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M512 132 A380 380 0 1 0 512 892"/>
|
||||
<path d="M512 512 L377.77 434.50"/>
|
||||
<path d="M512 512 L698.20 404.50"/>
|
||||
</g>
|
||||
<circle cx="512" cy="512" r="24" fill="#FFFFFF"/>
|
||||
<circle cx="816.06" cy="207.94" r="18" fill="#FF6A3D"/>
|
||||
<circle cx="884.39" cy="297.00" r="18" fill="#FF6A3D"/>
|
||||
<circle cx="927.35" cy="400.71" r="18" fill="#FF6A3D"/>
|
||||
<circle cx="942.00" cy="512.00" r="18" fill="#FF6A3D"/>
|
||||
<circle cx="927.35" cy="623.29" r="18" fill="#FF6A3D"/>
|
||||
<circle cx="884.39" cy="727.00" r="18" fill="#FF6A3D"/>
|
||||
<circle cx="816.06" cy="816.06" r="18" fill="#FF6A3D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 865 B |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"FLOW": { "appIcon": "#ABC5B5", "primary": "#2F7A68", "accent": "#ABC5B5", "foreground": "#0F2A24" },
|
||||
"MOMENTUM": { "appIcon": "#111820", "primary": "#FF6A3D", "accent": "#FF6A3D", "foreground": "#F5F1EA" },
|
||||
"CONTROL": { "appIcon": "#324A5F", "primary": "#324A5F", "accent": "#4A6B82", "foreground": "#14233A" }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "display": "Syne", "ui": "Manrope" }
|
||||
@@ -1,10 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cursor-browser alias for stage UI.
|
||||
* Optional DNS fallback for Cursor browser → internal stage UI.
|
||||
*
|
||||
* Why: Chromium treats *.local as mDNS and often ignores unicast DNS / hosts.
|
||||
* This proxy serves http://ui.stage.eventhub.test:8787 → https://stage
|
||||
* with Host/SNI ui.stage.eventhub.local (Traefik + existing cert).
|
||||
* Canonical access is direct HTTPS (valid public DNS + TLS):
|
||||
* https://stage.calentiq.com/ (client)
|
||||
* https://admin.stage.calentiq.com/
|
||||
*
|
||||
* Use this proxy ONLY when Cursor/Chromium cannot resolve the stage hostname
|
||||
* (e.g. *.local mDNS quirks) — not because of certificates.
|
||||
*
|
||||
* Serves http://ui.stage.eventhub.test:8787 → https://ui.stage.eventhub.local
|
||||
* with Host/SNI rewrite for Traefik.
|
||||
*
|
||||
* Usage:
|
||||
* node stage-browser-alias-proxy.mjs
|
||||
|
||||