diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d6b53ce --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# AGENTS.md — docs.a2v.space + +Hugo static site with custom theme. Russian-language personal documentation/knowledge base. + +## Commands + +- `hugo server` — dev server (live reload) +- `hugo` — build to `public/` +- No package manager, no build scripts, no tests, no linter. + +## Structure + +- `hugo.toml` — site config (TOML). Defines warm color palette params, menu, taxonomies (tags/categories/statuses), JSON output for search. +- `content/` — 7 sections: `diary/`, `kb/`, `plans/`, `guides/`, `creds/`, `leadera/`, `personal-site/`. Front matter is TOML (`+++`). +- `themes/a2v/` — custom theme (23 templates). All layout logic lives here. +- `static/` — CSS and JS at project root, **not** in the theme directory. + - `static/js/search.js` — Fuse.js client-side search against Hugo-generated `index.json` + - `static/js/creds.js` — Web Crypto API (AES-256-GCM + PBKDF2). Decryption on page; encryption via `window.encryptCreds(data, password)` in browser console. + - `static/css/custom.css` — all custom styles (434 lines, CSS variables from hugo.toml params) +- `BRIEF.md` — original spec with seed content and structure reference. + +## Key conventions + +- Archetype sets `draft = true` by default. Undraft with `hugo --buildDrafts` or remove `draft = true` from front matter. +- `relativeURLs = true` in config — all URLs are relative, no baseURL needed for local dev. +- Bootstrap 5.3.2 and Fuse.js 7.0.0 loaded from CDN (no local copies). +- Each section can have its own list/single templates under `themes/a2v/layouts/
/`. +- The sidebar partial (`themes/a2v/layouts/partials/sidebar.html`) is context-aware: shows tags, statuses, or services depending on the current section. +- The `source-path` partial renders the local filesystem path of each content file for easy editing. +- `shortcodes/encrypted-creds.html` is a passthrough wrapper for encrypted credential payloads. + +## Content front matter patterns + +- **diary**: `date`, `title`, `tags` +- **kb / guides**: `title`, `tags`, `draft` +- **plans**: `title`, `date`, `plan_date`, `status` (planned/in_progress/done/cancelled), `done_date`, `context_quote` +- **creds**: `title`, `description`, `service` — body contains base64-encoded encrypted payload +- **leadera / personal-site**: `title`, `tags` + +## Gotchas + +- Static assets must go in `static/` at project root, not `themes/a2v/static/` (the theme static dir is empty). +- The search index is generated by `themes/a2v/layouts/_default/index.json` — if you add a new section, it will be included automatically, but the Russian section label mapping is hardcoded in `search.js`. +- `hugo.toml` has `canonifyURLs = false` — do not set to true or relative URLs break. +- Hugo version installed: v0.159.1+extended (Homebrew). The theme requires ≥ 0.92.0. +- No `head.html` partial exists — the `` block is inline in `baseof.html` with a `{{ block "head" }}` extension point. diff --git a/content/leadera/_index.md b/content/leadera/_index.md new file mode 100644 index 0000000..2dfae7a --- /dev/null +++ b/content/leadera/_index.md @@ -0,0 +1,11 @@ +--- +title: "Leadera" +weight: 1 +--- + +# Leadera + +SaaS-система управления IT-ландшафтом с диаграммами Ганта. + +- **Прод:** [leadera.pro](https://leadera.pro) +- **Дев:** [sand.a2v.space](https://sand.a2v.space) diff --git a/content/guides/leadera-audit.md b/content/leadera/leadera-audit.md similarity index 100% rename from content/guides/leadera-audit.md rename to content/leadera/leadera-audit.md diff --git a/content/leadera/leadera-gantt-excel-export.md b/content/leadera/leadera-gantt-excel-export.md new file mode 100644 index 0000000..3827433 --- /dev/null +++ b/content/leadera/leadera-gantt-excel-export.md @@ -0,0 +1,103 @@ +--- +title: "Leadera — Экспорт диаграммы Ганта в Excel" +date: 2026-04-09 +lastmod: 2026-04-09 +tags: ["leadera", "gantt", "excel", "export"] +weight: 120 +--- + +# Экспорт диаграммы Ганта в Excel + +**Дата:** 09.04.2026 +**Статус:** Требования +**Проект:** Leadera + +--- + +## Общее описание + +Пользователь нажимает кнопку экспорта в интерфейсе диаграммы Ганта. Система запрашивает диапазон дат выгрузки (по умолчанию — текущий видимый диапазон из интерфейса). После указания диапазона скачивается Excel-файл с визуальным представлением диаграммы Ганта. + +--- + +## Вводные данные + +- **Источник:** текущая диаграмма Ганта проекта (один проект = одна диаграмма) +- **Диапазон дат:** пользователь указывает начальную и конечную дату. По умолчанию подставляется видимый в момент запроса диапазон из интерфейса +- **Масштаб таймлайна:** берётся тот, который выбран пользователем в интерфейсе в момент запроса (не сохранённый в системе, а текущий). Варианты: по ячейкам или по дням + +--- + +## Структура Excel-файла + +### Шапка (первые строки) +- Название диаграммы (название проекта) +- Диапазон дат, в рамках которого сделана выгрузка + +### Лист — один + +Вся диаграмма размещается на одном листе. + +### Секции +- Секции отображаются как **визуальная группировка строк** (не отдельные листы) +- Название секции — строка-заголовок над группой задач + +### Задачи +Каждая задача — отдельная строка. Данные задачи: +- **Название** (текстовая колонка слева) +- **Дата начала** (текстовая колонка слева) +- **Дата конца** (текстовая колонка слева) +- **Цвет** — ячейки таймлайна, соответствующие задаче, закрашены в цвет задачи + +### Таймлайн +- Таймлайн представлен колонками дат в верхней части (горизонтальная ось времени) +- Масштаб колонок соответствует выбранному пользователем в момент экспорта (по ячейкам / по дням) +- Задачи отображаются как **merged cells** по соответствующим колонкам дат — визуальная полоса диаграммы Ганта + +### Вехи +- Отображаются **отдельной строкой** (как задача, но с маркировкой вехи) +- Позиционируются на соответствующей дате в таймлайне + +### Зависимости +- **Не отображаются** (стрелочки/связи между задачами не нужны) + +--- + +## Пользовательский сценарий + +1. Пользователь открывает диаграмму Ганта проекта +2. Настраивает масштаб таймлайна (по ячейкам / по дням) +3. Нажимает кнопку «Экспорт в Excel» +4. Система показывает диалог с выбором диапазона дат (предзаполнен текущим видимым диапазоном) +5. Пользователь подтверждает / корректирует диапазон +6. Система генерирует Excel-файл и инициирует скачивание + +--- + +## Технические требования + +### Backend (Go API) +- Новый endpoint для генерации Excel (например, `GET /api/v1/gantt/:projectId/export`) +- Query-параметры: `dateFrom`, `dateTo`, `scale` (cell|day) +- Ответ: binary Excel-файл (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`) +- Авторизация: обязательна (только участники проекта) +- Библиотека для генерации Excel: `excelize` (Go) + +### Frontend (Angular) +- Кнопка экспорта в тулбаре диаграммы Ганта +- Модальное окно выбора диапазона дат с datepicker'ами +- Предзаполнение диапазона из текущего видимого viewport'а +- Скачивание файла через `Blob` / `saveAs` + +### Фильтрация данных +- Выгружаются все задачи и вехи, которые: + - Начинаются в диапазоне, ИЛИ + - Заканчиваются в диапазоне, ИЛИ + - Начинаются до диапазона и заканчиваются после (перекрывают целиком) +- Секции включаются, если в них есть хотя бы одна попавшая задача + +--- + +## Открытые вопросы + +_(Нет)_ diff --git a/content/leadera/leadera-gantt-filters.md b/content/leadera/leadera-gantt-filters.md new file mode 100644 index 0000000..5ed2423 --- /dev/null +++ b/content/leadera/leadera-gantt-filters.md @@ -0,0 +1,278 @@ +--- +title: "Leadera — Фильтры и реорганизация списка диаграмм Ганта" +date: 2026-04-07 +lastmod: 2026-04-07 +tags: ["leadera", "gantt", "api", "frontend", "requirements"] +weight: 120 +--- + +# Фильтры и реорганизация списка диаграмм Ганта + +**Дата:** 07.04.2026 +**Статус:** Требования +**Проект:** Leadera (dev — sand.a2v.space) + +--- + +## 1. Постановка задачи + +Список диаграмм Ганта должен показывать **все доступные пользователю диаграммы** из всех пространств (spaces), с возможностью фильтрации и поиска. Сейчас список привязан к одному пространству — нужно убрать эту зависимость и добавить удобный toolbar. + +--- + +## 2. Изменения API + +### 2.1. Новый endpoint списка диаграмм + +**Было:** +``` +GET /api/v1/spaces/{spaceId}/gantt-charts?page=1&per_page=10 +``` + +**Стало:** +``` +GET /api/v1/gantt-charts?page=1&per_page=10 +``` + +Пользователь видит все диаграммы из **всех пространств**, где он является подтверждённым участником (статус `accepted`). + +### 2.2. Параметры фильтрации и поиска + +| Параметр | Тип | Описание | +|----------|-----|----------| +| `page` | int | Номер страницы (default: 1) | +| `per_page` | int | Записей на странице (default: 10) | +| `space_id` | UUID (optional) | Фильтр по конкретному пространству | +| `created_by` | UUID (optional) | Фильтр по создателю диаграммы | +| `search` | string (optional) | Поиск по назвению и описанию (ILIKE, case-insensitive) | +| `sort` | string (optional) | Сортировка: `created_at`, `updated_at`, `title` (default: `updated_at`) | +| `order` | string (optional) | `asc` / `desc` (default: `desc`) | + +**Примеры:** +``` +GET /api/v1/gantt-charts?search=миграция +GET /api/v1/gantt-charts?space_id=0b334ded-...&created_by=a1b2c3d4-... +GET /api/v1/gantt-charts?page=2&per_page=20&sort=title&order=asc +``` + +### 2.3. Изменение структуры ответа + +Добавить поле `space` в каждую диаграмму: + +```json +{ + "data": [ + { + "id": "uuid", + "title": "Миграция backend", + "description": "...", + "created_by": "uuid", + "created_by_name": "Антон Агальцов", + "created_at": "2026-04-01T10:00:00Z", + "updated_at": "2026-04-07T09:00:00Z", + "space": { + "id": "uuid", + "name": "IT Ландшафт", + "color": "#c4623a" + }, + "is_archived": false + } + ], + "pagination": { + "page": 1, + "per_page": 10, + "total": 42, + "total_pages": 5 + } +} +``` + +### 2.4. Endpoint для списка фильтров + +Вспомогательный endpoint для заполнения dropdown-фильтров: + +``` +GET /api/v1/gantt-charts/filters +``` + +**Ответ:** +```json +{ + "spaces": [ + { "id": "uuid", "name": "IT Ландшафт", "color": "#c4623a" }, + { "id": "uuid", "name": "DevOps", "color": "#27ae60" } + ], + "creators": [ + { "id": "uuid", "display_name": "Антон Агальцов" }, + { "id": "uuid", "display_name": "Иван Петров" } + ] +} +``` + +**Логика `creators`:** уникальные создатели всех диаграмм, доступных текущему пользователю. Получается через DISTINCT-запрос по `created_by` с JOIN на users. + +### 2.5. Backend — изменения в Go + +**Файлы для изменения:** + +#### `internal/handlers/gantt_handler.go` + +- Добавить новый handler `ListAllGanttCharts` — без привязки к space +- Зарегистрировать маршрут `GET /gantt-charts` (без `/spaces/:id/`) +- Парсить query-параметры: `space_id`, `created_by`, `search`, `sort`, `order` +- Старый маршрут `GET /spaces/:id/gantt-charts` — оставить для обратной совместимости (или удалить, если не используется) + +#### `internal/service/gantt_service.go` + +- Новый метод `ListAll(ctx, userId, filters)` — запрашивает все spaces пользователя (через `space_member` с `status=accepted`), затем выбирает все gantt_charts из этих spaces с фильтрами + +#### `internal/repository/gantt_repository.go` + +- Новый метод с динамическим SQL: + - Базовый JOIN: `gantt_charts` → `spaces` → `space_members` + - WHERE: `space_members.user_id = $1 AND space_members.status = 'accepted'` + - Опциональные фильтры: `AND space_id = $N`, `AND created_by = $N`, `AND (title ILIKE '%' || $N || '%' OR description ILIKE '%' || $N || '%')` + - Возвращать поле `space` (id, name, color) через JOIN + +#### `internal/dto/gantt/` + +- Новые DTO: `GanttChartListFilters`, `GanttChartWithSpaceResponse`, `GanttFiltersResponse` + +**SQL (пример):** +```sql +SELECT gc.*, s.id as space_id, s.name as space_name, s.color as space_color, + u.display_name as created_by_name +FROM gantt_charts gc +JOIN spaces s ON s.id = gc.space_id +JOIN space_members sm ON sm.space_id = s.id +LEFT JOIN users u ON u.id = gc.created_by +WHERE sm.user_id = $1 + AND sm.status = 'accepted' + AND ($2::uuid IS NULL OR gc.space_id = $2) + AND ($3::uuid IS NULL OR gc.created_by = $3) + AND ($4::text IS NULL OR gc.title ILIKE '%' || $4 || '%' + OR gc.description ILIKE '%' || $4 || '%') +ORDER BY + CASE WHEN $5 = 'title' THEN gc.title END, + CASE WHEN $5 = 'created_at' THEN gc.created_at::text END, + gc.updated_at DESC +LIMIT $6 OFFSET $7; +``` + +--- + +## 3. Изменения Frontend (Angular) + +### 3.1. Toolbar фильтров + +Добавить toolbar над списком диаграмм: + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ [🔍 Поиск... ] [▼ Пространство] │ +│ [▼ Создатель] [▼ Сортировка] [Сбросить фильтры] │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Компоненты:** +- **Строка поиска** — input с debounce (300ms), поиск по назвению и описанию +- **Dropdown «Пространство»** — список из `/gantt-charts/filters`, с цветовой меткой +- **Dropdown «Создатель»** — список уникальных создателей +- **Dropdown «Сортировка»** — по дате обновления, дате создания, названию +- **Кнопка «Сбросить»** — очищает все фильтры + +**Состояние фильтров в URL** (query params): +``` +/gantt?search=миграция&space_id=xxx&sort=updated_at +``` +Это позволяет делиться ссылкой с уже настроенными фильтрами. + +### 3.2. Карточка диаграммы — бейдж пространства + +На каждой карточке добавить бейдж с названием пространства: + +``` +┌─────────────────────────────────────┐ +│ 🟠 IT Ландшафт │ ← бейдж с цветом пространства +│ Миграция backend │ +│ Перевод PHP→Go, этап 2 │ +│ Создал: Антон А. · 07.04.2026 │ +└─────────────────────────────────────┘ +``` + +### 3.3. Форма создания диаграммы — выбор пространства + +В модальном окне создания: +- Dropdown со списком пространств, где пользователь имеет роль **editor** или выше +- Валидация: если пользователь не editor ни в одном пространстве — показать сообщение «Нет доступных пространств для создания» + +**Запрос доступных пространств:** переиспользовать существующий `GET /api/v1/spaces` с фильтрацией на клиенте по `role ∈ [owner, admin, editor]`, либо добавить параметр `?min_role=editor` на backend. + +### 3.4. Изменение routing + +**Было:** +``` +/gantt → список гантов текущего пространства +``` + +**Стало:** +``` +/gantt → список всех доступных гантов (из всех пространств) +``` + +Убрать зависимость от `SpaceStorageService.getSelectedSpaceId()` в списке гантов. + +### 3.5. Frontend — файлы для изменения + +| Файл | Изменение | +|------|-----------| +| `pages/gantt/services/gantt.service.ts` | Новый метод `listAll(filters)`, `getFilters()` | +| `pages/gantt/gantt.component.ts` | Подключить toolbar, парсинг URL-параметров | +| `pages/gantt/gantt.component.html` | Добавить toolbar, бейджи на карточках | +| `pages/gantt/components/gantt-card/` | Добавить бейдж пространства | +| `pages/gantt/components/create-chart-modal/` | Dropdown выбора пространства | +| `core/models/` | Обновить модели: добавить `space` в GanttChart | + +--- + +## 4. Обратная совместимость + +| Endpoint | Статус | +|----------|--------| +| `GET /api/v1/spaces/{id}/gantt-charts` | Сохранить (для детального view внутри пространства) | +| `GET /api/v1/gantt-charts` | **Новый** — глобальный список | +| `GET /api/v1/gantt-charts/filters` | **Новый** — данные для dropdown'ов | +| `POST /api/v1/spaces/{id}/gantt-charts` | Сохранить (создание в контексте пространства) | +| Все CRUD для sections/tasks | Без изменений | + +--- + +## 5. Критерии приёмки + +- [ ] Endpoint `GET /api/v1/gantt-charts` возвращает все доступные диаграммы +- [ ] Фильтр по `space_id` — показывает только диаграммы конкретного пространства +- [ ] Фильтр по `created_by` — показывает только диаграммы выбранного создателя +- [ ] Поиск `search` — ищет по title и description (case-insensitive) +- [ ] Dropdown фильтров заполняется реальными данными из `/filters` +- [ ] Карточка диаграммы показывает бейдж пространства с цветом +- [ ] Форма создания предлагает только пространства с ролью ≥ editor +- [ ] Состояние фильтров сохраняется в URL (можно поделиться ссылкой) +- [ ] Старые endpoints (`/spaces/{id}/gantt-charts`) продолжают работать +- [ ] Swagger документация обновлена + +--- + +## 6. Порядок реализации + +1. Backend: DTO для новых запросов/ответов +2. Backend: Repository — SQL-запрос с фильтрами + JOIN на spaces +3. Backend: Service — `ListAll`, `GetFilters` +4. Backend: Handler — `ListAllGanttCharts`, `GetGanttFilters`, регистрация маршрутов +5. Backend: Swagger — обновить документацию +6. Frontend: Модели — обновить GanttChart, добавить Space в ответ +7. Frontend: Service — `listAll()`, `getFilters()` +8. Frontend: Toolbar компонент — поиск + dropdown'ы +9. Frontend: Карточка — бейдж пространства +10. Frontend: Модальное окно создания — dropdown пространства +11. Frontend: URL-параметры — синхронизация фильтров с router +12. Тестирование diff --git a/content/leadera/leadera-gantt-public-access.md b/content/leadera/leadera-gantt-public-access.md new file mode 100644 index 0000000..84ff79b --- /dev/null +++ b/content/leadera/leadera-gantt-public-access.md @@ -0,0 +1,125 @@ +--- +title: "Leadera — Публичный доступ к диаграмме Ганта" +date: 2026-04-09 +lastmod: 2026-04-09 +tags: ["leadera", "gantt", "public-access", "sharing"] +weight: 130 +--- + +# Публичный доступ к диаграмме Ганта (Share) + +**Дата:** 09.04.2026 +**Статус:** Требования +**Проект:** Leadera + +--- + +## Общее описание + +Пользователь может создать публичную ссылку на фрагмент диаграммы Ганта, доступную для просмотра без авторизации. Открывший ссылку видит диаграмму в read-only режиме с возможностью масштабирования таймлайна, но без каких-либо элементов управления (создание, редактирование, удаление задач/секций/вех). + +--- + +## Пользовательский сценарий (создатель ссылки) + +1. Пользователь открывает диаграмму Ганта проекта +2. Нажимает кнопку «Экспорт» / «Общий доступ» +3. Указывает диапазон дат, который должен отображаться на диаграмме +4. Система генерирует уникальную ссылку (UUID) и показывает её пользователю +5. Пользователь копирует ссылку и передаёт заинтересованным лицам + +### Управление ссылками +- Вызывается через меню экспорта → модальное окно «Общий доступ» +- В модальном окне отображается список созданных ссылок с указанием диапазона дат +- Каждую ссылку можно **удалить** (полное удаление из БД, не soft delete) +- Ссылки **нельзя редактировать** — только создавать и удалять +- Ограничений на количество активных ссылок нет +- Статистика просмотров (сколько раз открывали) — **не нужна** + +--- + +## Пользовательский сценарий (зритель по ссылке) + +1. Зритель переходит по ссылке (без авторизации) +2. Открывается отдельная страница с диаграммой Ганта +3. Зритель видит: + - Задачи и вехи, попадающие в заданный диапазон + - Секции (заголовки группировок) + - Таймлайн с цветовой раскраской задач +4. Зритель может: + - Масштабировать таймлайн + - Переключать отображение «по ячейкам» / «по дням» +5. Зритель **не может**: + - Создавать, редактировать, удалять задачи / секции / вехи + - Видеть любые контролы управления (кнопки создания, контекстные меню, drag-and-drop и т.д.) + - Авторизоваться через эту страницу + +--- + +## Данные и фильтрация + +### Что попадает в представление +Задачи и вехи, у которых хотя бы одно из условий верно: +- Дата начала попадает в диапазон ссылки +- Дата конца попадает в диапазон ссылки +- Задача начинается до диапазона и заканчивается после (полностью перекрывает) + +Секции включаются, если в них есть хотя бы одна попавшая задача. + +### API возвращает +- Только задачи и вехи, попадающие в диапазон (не всю диаграмму) +- Информацию о секциях, содержащих попавшие задачи +- Минимальный набор полей, необходимый для отрисовки (без внутренних ID пользователей и т.п.) + +--- + +## Технические требования + +### Ссылки +- Формат: UUID v4 (каждый раз новый) +- Срок действия: бессрочно (пока не удалена) +- Удаление: **жёсткое** (hard delete из БД) + +### Backend (Go API) + +#### Таблица БД: `gantt_public_links` +- `id` (UUID, PK) +- `project_id` (FK → projects) +- `date_from` (date) +- `date_to` (date) +- `created_by` (FK → users) +- `created_at` (timestamp) + +#### Endpoints +- `POST /api/v1/gantt/:projectId/public-links` — создать ссылку (авторизован, участник проекта) + - Body: `{ dateFrom, dateTo }` + - Response: `{ url: "https://leadera.pro/share/" }` +- `GET /api/v1/gantt/:projectId/public-links` — список ссылок проекта (авторизован, участник) +- `DELETE /api/v1/gantt/public-links/:uuid` — удалить ссылку (авторизован, создатель) +- `GET /api/v1/public/gantt/:uuid` — получить данные диаграммы по ссылке (без авторизации) + - Response: задачи, вехи, секции в рамках диапазона + +#### Rate limiting +- На публичный endpoint `GET /api/v1/public/gantt/:uuid` — rate limiting для защиты от перебора +- Рекомендация: 60 запросов в минуту на IP + +### Frontend (Angular) + +#### Модальное окно «Общий доступ» +- Вызывается из меню экспорта +- Содержит: + - Форму создания ссылки (диапазон дат + кнопка «Создать») + - Список существующих ссылок (диапазон + URL + кнопка «Копировать» + кнопка «Удалить») + +#### Публичная страница `/share/:uuid` +- Отдельный route, **не требует авторизации** +- Отображает диаграмму Ганта в read-only режиме +- Доступны только: масштабирование таймлайна, переключение ячейки/дни +- Все контролы управления скрыты (удалены из шаблона, не просто disabled) +- Минимальный layout — без навигации проекта, без сайдбара + +--- + +## Открытые вопросы + +_(Нет)_ diff --git a/content/leadera/leadera-milestones.md b/content/leadera/leadera-milestones.md new file mode 100644 index 0000000..dfb12be --- /dev/null +++ b/content/leadera/leadera-milestones.md @@ -0,0 +1,329 @@ +--- +title: "Leadera: Вехи (Milestones) на диаграмме Ганта" +date: 2026-04-08 +tags: ["leadera", "gantt", "angular", "go", "api", "ux"] +--- + +## Бизнес-требования + +### BR-1: Сущность «Веха» (Milestone) + +Веха — это маркер на таймлайне диаграммы Ганта, обозначающий важную дату в проекте. В отличие от задач, веха не имеет длительности и привязана к конкретной дате. + +**Поля вехи:** + +| Поле | Тип | Описание | +|------|-----|----------| +| id | UUID | Идентификатор | +| gantt_chart_id | UUID | Принадлежность к диаграмме (не к секции!) | +| title | string | Название вехи (обязательное, 1–255 символов) | +| date | date | Дата вехи (без времени, обязательная) | +| color | string | Цвет маркера (HEX, по умолчанию `#dc3545` — красный) | +| created_by | UUID | Кто создал (не отображается в UI, хранится для аудита) | +| updated_by | UUID | Кто обновил (не отображается в UI) | +| created_at | timestamp | Дата создания | +| updated_at | timestamp | Дата обновления | +| deleted_at | timestamp | Soft delete | + +**Права доступа:** Создание, редактирование и удаление вех доступно участникам с правами выше «только чтение» (аналогично задачам). + +**Особенности:** +- Вех может быть несколько в проекте +- Несколько вех могут иметь одну и ту же дату +- Вехи сортируются строго по дате (по возрастанию) +- Вехи не привязаны к секциям — они отображаются **сквозь все секции** + +--- + +### BR-2: Управление вехами (модальное окно) + +**Вызов:** Кнопка «Вехи» рядом с кнопкой «Создать секцию» на странице диаграммы. + +**Модальное окно содержит:** +- Заголовок: «Вехи проекта» +- Список всех вех текущей диаграммы (сортировка по дате по возрастанию) +- Каждая веха в списке показывает: цвет (индикатор), название, дату, кнопки «Редактировать» и «Удалить» +- Кнопка «Добавить веху» + +**Форма добавления/редактирования вехи:** +- Название — текстовый ввод (обязательное, 1–255 символов) +- Дата — datepicker (обязательное) +- Цвет — выбор цвета (по аналогии с палитрой цветов в задачах) +- По умолчанию цвет = красный (`#dc3545`) + +**Удаление:** с подтверждением (confirm dialog). + +--- + +### BR-3: Отображение вех на таймлайне + +**Визуальное представление:** +- **Маркер-флажок** (flag marker) на шкале таймлайна +- От маркера вниз идёт **пунктирная вертикальная линия** через все секции (на всю высоту диаграммы) +- Цвет линии и маркера = цвет вехи + +**Позиционирование:** +- Веха ставится ровно на свою дату на шкале времени +- Если дата вехи выходит за рамки текущего диапазона задач — таймлайн **расширяется** (аналогично задачам) +- При изменении масштаба (days/weeks/months/quarters) позиция вехи пересчитывается + +**Tooltip при наведении:** +- При наведении на маркер/линию — показывается tooltip с названием вехи +- Если на одну дату приходится несколько вех — в tooltip показываются **все** вехи этой даты с их названиями (каждая с индикацией своего цвета) + +--- + +## Технические требования + +### База данных + +#### Новая таблица: gantt_milestones + +```sql +CREATE TABLE gantt_milestones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + gantt_chart_id UUID NOT NULL REFERENCES gantt_charts(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + date DATE NOT NULL, + color VARCHAR(20) NOT NULL DEFAULT '#dc3545', + created_by UUID NOT NULL REFERENCES users(id), + updated_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX idx_gantt_milestones_chart ON gantt_milestones(gantt_chart_id); +CREATE INDEX idx_gantt_milestones_date ON gantt_milestones(date); +``` + +--- + +### Backend (Go API) + +#### Этап 1: Миграция + Домен + +**Промт для агента:** + +> Добавь в проект новую таблицу `gantt_milestones`. +> +> 1. Создай миграцию в `cmd/migrate/`: +> - Новая таблица `gantt_milestones` с полями: id (UUID PK), gantt_chart_id (UUID FK → gantt_charts, ON DELETE CASCADE), title (VARCHAR 255 NOT NULL), date (DATE NOT NULL), color (VARCHAR 20 NOT NULL DEFAULT '#dc3545'), created_by (UUID FK → users), updated_by (UUID FK → users), created_at, updated_at, deleted_at. Индексы по gantt_chart_id и date. +> +> 2. Создай доменную модель `GanttMilestone` в `internal/domain/gantt.go`: +> ```go +> type GanttMilestone struct { +> ID UUID `json:"id"` +> GanttChartID UUID `json:"gantt_chart_id"` +> Title string `json:"title"` +> Date time.Time `json:"date"` +> Color string `json:"color"` +> CreatedBy UUID `json:"created_by"` +> UpdatedBy UUID `json:"updated_by"` +> CreatedAt time.Time `json:"created_at"` +> UpdatedAt time.Time `json:"updated_at"` +> DeletedAt *time.Time `json:"deleted_at,omitempty"` +> } +> ``` + +#### Этап 2: CRUD для GanttMilestones + +**Промт для агента:** + +> Реализуй полный CRUD для сущности `GanttMilestone` по аналогии с существующими handlers/services/repositories в проекте. +> +> **Роуты:** +> - `GET /api/gantt-charts/:chartId/milestones` — список вех диаграммы +> - `POST /api/gantt-charts/:chartId/milestones` — создать веху +> - `PUT /api/gantt-charts/:chartId/milestones/:milestoneId` — обновить веху +> - `DELETE /api/gantt-charts/:chartId/milestones/:milestoneId` — удалить веху (soft delete) +> +> **Repository:** `internal/repository/gantt_milestone_repository.go` +> - `GetByChartID(ctx, chartID) ([]domain.GanttMilestone, error)` — все неудалённые вехи диаграммы, отсортированные по date ASC +> - `GetByID(ctx, id) (*domain.GanttMilestone, error)` +> - `Create(ctx, milestone) error` +> - `Update(ctx, milestone) error` +> - `Delete(ctx, id) error` — soft delete (set deleted_at) +> - Все запросы — raw SQL через pgx, как в остальных репозиториях проекта +> +> **Service:** `internal/service/gantt_milestone_service.go` +> - Валидация: title (1–255 символов), date (обязательное), color (валидный HEX, default '#dc3545') +> - Проверка прав доступа: пользователь должен иметь права выше «только чтение» в пространстве (space) диаграммы +> - Проверка что chartId существует и не архивирован +> +> **Handler:** `internal/handler/gantt_milestone_handler.go` +> - Swagger-аннотации для всех эндпоинтов +> - Валидация входных данных +> - Ошибки в формате проекта +> +> **DTO:** `internal/dto/gantt/milestones_dto.go` +> ```go +> type CreateGanttMilestoneRequest struct { +> Title string `json:"title" binding:"required,min=1,max=255"` +> Date string `json:"date" binding:"required"` +> Color *string `json:"color" binding:"omitempty,max=20"` +> } +> +> type UpdateGanttMilestoneRequest struct { +> Title *string `json:"title" binding:"omitempty,min=1,max=255"` +> Date *string `json:"date" binding:"omitempty"` +> Color *string `json:"color" binding:"omitempty,max=20"` +> } +> +> type GanttMilestoneResponse struct { +> ID domain.UUID `json:"id"` +> GanttChartID domain.UUID `json:"gantt_chart_id"` +> Title string `json:"title"` +> Date string `json:"date"` +> Color string `json:"color"` +> CreatedByUser *CreatedByUpdatedUser `json:"created_by_user"` +> UpdatedByUser *CreatedByUpdatedUser `json:"updated_by_user"` +> CreatedAt time.Time `json:"created_at"` +> UpdatedAt time.Time `json:"updated_at"` +> } +> +> type GanttMilestonesListResponse struct { +> Milestones []GanttMilestoneResponse `json:"milestones"` +> } +> ``` +> +> **Регистрация роутов:** Добавь группу milestone-роутов в роутер рядом с существующими роутами секций/задач. Проверка прав через существующий middleware. + +#### Этап 3: Включение вех в ответ диаграммы + +**Промт для агента:** + +> При запросе данных диаграммы Ганта (полная загрузка) включай связанные вехи в ответ. +> +> 1. Добавь эндпоинт или расширь существующий `GET /api/gantt-charts/:chartId` — включить поле `milestones` с массивом `GanttMilestoneResponse` +> 2. Вехи с `deleted_at IS NOT NULL` не возвращаются +> 3. Вехи сортируются по `date` ASC +> 4. Вехи загружаются вместе с данными диаграммы (tasks, sections) — один запрос при открытии Ганта + +--- + +### Frontend (Angular) + +#### Этап FE-1: Модели + Сервис + +**Промт для агента:** + +> Добавь в проект поддержку вех (milestones) на диаграмме Ганта. +> +> 1. В `pages/gantt/models/` (или соответствующем файле моделей) добавь: +> +> ```typescript +> export interface GanttMilestone { +> id: string; +> gantt_chart_id: string; +> title: string; +> date: string; +> color: string; +> created_by_user?: { id: string; full_name: string }; +> updated_by_user?: { id: string; full_name: string }; +> created_at: string; +> updated_at: string; +> } +> +> export interface GanttMilestoneCreateRequest { +> title: string; +> date: string; +> color?: string; +> } +> +> export interface GanttMilestoneUpdateRequest { +> title?: string; +> date?: string; +> color?: string; +> } +> ``` +> +> 2. Добавь `milestones: GanttMilestone[]` в интерфейс/модель полной загрузки данных диаграммы +> +> 3. Создай сервис `pages/gantt/services/gantt-milestone.service.ts`: +> - Inject HttpClient +> - Методы: +> - `getMilestones(chartId: string): Observable` +> - `createMilestone(chartId: string, data: GanttMilestoneCreateRequest): Observable` +> - `updateMilestone(chartId: string, milestoneId: string, data: GanttMilestoneUpdateRequest): Observable` +> - `deleteMilestone(chartId: string, milestoneId: string): Observable` +> - Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService) + +#### Этап FE-2: Модальное окно управления вехами + +**Промт для агента:** + +> Создай компонент модального окна для управления вехами диаграммы Ганта. +> +> 1. Создай компонент `pages/gantt/components/milestones-modal/` +> +> 2. **Кнопка вызова:** Добавь кнопку «Вехи» рядом с кнопкой «Создать секцию» в toolbar диаграммы. По клику — открытие модального окна через ng-bootstrap NgbModal. +> +> 3. **Модальное окно** (ng-bootstrap modal): +> - Заголовок: «Вехи проекта» +> - Список вех (сортировка по date ASC): +> - Каждый элемент: цветной кружок (color), название, дата (форматированная), кнопки ✏️ (редактировать) и 🗑️ (удалить с confirm) +> - Кнопка «Добавить веху» внизу списка +> - Если вех нет — сообщение «Вех пока нет. Добавьте первую!» +> +> 4. **Форма добавления/редактирования** (inline в модальном окне): +> - Название — текстовый ввод (обязательное) +> - Дата — ng-bootstrap datepicker (обязательное) +> - Цвет — палитра цветов (использовать тот же компонент/подход, что и для задач) +> - По умолчанию цвет = `#dc3545` (красный) +> - Кнопки «Сохранить» и «Отмена» +> +> 5. При сохранении — вызов соответствующего метода сервиса (create/update) +> 6. При удалении — confirm dialog → вызов deleteMilestone +> 7. После любого изменения — обновить список вех и обновить данные на канвасе (через сервис/GanttStore) +> +> 8. Инжектировать GanttMilestoneService и NgbModal + +#### Этап FE-3: Отображение вех на таймлайне (Konva canvas) + +**Промт для агента:** + +> Отобрази вехи (milestones) на canvas-диаграмме Ганта. +> +> Проект использует Konva.js для рендеринга. +> +> 1. При загрузке данных диаграммы — извлечь массив `milestones` +> +> 2. Для каждой вехи нарисовать на canvas: +> - **Маркер-флажок** (flag marker) на позиции даты вехи на шкале времени +> - Стиль: маленький треугольник или флаг на верхней границе таймлайна +> - Цвет = milestone.color +> - **Пунктирная вертикальная линия** от маркера вниз через все секции (на всю высоту области секций) +> - Стиль: Konva.Line, dash: [5, 5], strokeWidth: 1.5, color = milestone.color, opacity: 0.7 +> +> 3. **Позиционирование:** +> - Рассчитать X-позицию по дате вехи с использованием текущей шкалы времени (как для задач) +> - Если дата вехи выходит за текущий диапазон — расширить диапазон (влияет на общую ширину таймлайна) +> - При изменении масштаба (days/weeks/months/quarters) — пересчитать позиции всех вех +> - При скролле — вехи скроллятся вместе с задачами +> +> 4. **Tooltip при наведении:** +> - При наведении на маркер или пунктирную линию — показать Konva.Tooltip или HTML tooltip: +> - Если веха одна на эту дату — показать название +> - Если несколько вех на одну дату — показать список: цветной кружок + название для каждой +> +> 5. **Z-index:** +> - Линии вех рисуются **поверх** фона секций, но **под** задачами +> - Маркеры-флажки — поверх всего (самый высокий z-index) +> +> 6. **Реактивность:** +> - При добавлении/удалении/редактировании вехи — перерисовать только вехи (без полного ре рендера задач) +> - Подписаться на изменения массива milestones через сервис/store + +--- + +## Порядок реализации + +| # | Этап | Сторона | Зависимости | +|---|------|---------|-------------| +| 1 | Миграция + Домен | Backend | — | +| 2 | CRUD вех | Backend | Этап 1 | +| 3 | Включение вех в ответ диаграммы | Backend | Этап 2 | +| 4 | Модели + Сервис | Frontend | — (параллельно с backend) | +| 5 | Модальное окно управления вехами | Frontend | Этапы 4 + backend 2 | +| 6 | Отображение вех на таймлайне | Frontend | Этапы 4 + backend 3 | diff --git a/content/leadera/leadera-section-constraints.md b/content/leadera/leadera-section-constraints.md new file mode 100644 index 0000000..4f00cfc --- /dev/null +++ b/content/leadera/leadera-section-constraints.md @@ -0,0 +1,407 @@ +--- +title: "Leadera: Ограничения секций в диаграмме Ганта" +date: 2026-04-06 +tags: ["leadera", "gantt", "angular", "go", "api", "ux"] +--- + +## Бизнес-требования + +### BR-1: Лимит одновременных задач (Concurrency Limit) + +У каждой секции появляется параметр **max_concurrent_tasks** (целое число ≥ 0, по умолчанию 0). + +**Поведение:** + +- Если `max_concurrent_tasks = 3` — в любой данный день в этой секции не может быть больше 3 активных задач одновременно +- Если `max_concurrent_tasks = 0` — ограничение отключено, лимита нет +- Пересечение считается по дате: задача занимает дни с `start_date` по `end_date` включительно. Две задачи «пересекаются», если у них есть хотя бы один общий день +- Ограничение работает **только в рамках одной секции**. Задачи из разных секций не влияют друг на друга + +**Срабатывание при:** + +1. Создании задачи — если добавление нарушает лимит → Alert +2. Перетаскивании задачи (drag) — если новая позиция нарушает лимит → Alert +3. Растягивании задачи (resize) — если новый размер нарушает лимит → Alert +4. Редактировании задачи (форма) — если изменённые даты нарушают лимит → Alert + +**Важно:** Действие **НЕ отменяется**. Задача создаётся/перемещается в любом случае, но пользователь видит предупреждение. + +**Текст Alert:** «В секции "[название секции]" на [дата/диапазон] превышен лимит одновременных задач (максимум: N)» + +--- + +### BR-2: Блоки секций (Section Blocks) + +У каждой секции появляется набор **блоков** — особый вид записей, отображаемых на таймлайне как заштрихованные области. + +**Сущность GanttSectionBlock:** + +| Поле | Тип | Описание | +|------|-----|----------| +| id | UUID | Идентификатор | +| section_id | UUID | Принадлежность к секции | +| title | string | Название (например: «Отпуск», «Командировка») | +| start_date | date | Начало интервала (минимум 1 день) | +| end_date | date | Конец интервала | +| block_concurrency | boolean | Запрещать ли пересечение обычных задач с данным блоком | + +**Валидация:** + +- `end_date` не может быть раньше `start_date` +- Все поля обязательны +- Блок может быть в прошлом + +**Визуальное отображение на Ганте:** + +- Заштрихованная область на **всю высоту секции** (включая все строки задач в секции) +- Название блока отображается **поверх штриховки с подложкой** (для читаемости) + +**Поведение block_concurrency:** + +- Если `block_concurrency = true` — при пересечении задачи с данным блоком показывается Alert (аналогично BR-1), но задача **всё равно создаётся/перемещается** +- Если `block_concurrency = false` — блок отображается визуально, но не влияет на задачи + +**Срабатывание при:** + +1. Создании задачи — пересекает блок с `block_concurrency = true` → Alert +2. Перетаскивании задачи (drag) → Alert +3. Растягивании задачи (resize) → Alert +4. Редактировании задачи (форма) → Alert + +**Текст Alert:** «В секции "[название секции]" задача пересекает блок "[название блока]" ([дата] — [дата])» + +--- + +### BR-3: Система Alert-ов + +Все предупреждения (от BR-1 и BR-2) выводятся в **единую область** над диаграммой Ганта. + +**Поведение:** + +- Alert-ы — Bootstrap Alert (warning), **постоянные** +- Пользователь **НЕ может** закрыть Alert вручную +- Alert исчезает **автоматически**, когда текущая дата прошла дату конфликта (конфликт больше не актуален) +- Проверка конфликтов — **только на фронтенде** (на основе загруженных данных: задачи секции, блоки, max_concurrent_tasks) +- Если Alert-ов много — область **сворачиваемая** с счётчиком в бейдже (например: «⚠️ 3 предупреждения») +- По умолчанию — развёрнуто +- Сворачивание/разворачивание — клик по заголовку области + +--- + +### BR-4: Настройки секции + +- Настройки совмещены с **существующим модальным окном редактирования секции** (не отдельное окно) + +**Окно создания секции** — добавляется: +- Поле «Макс. одновременных задач» (числовой ввод, 0 = без лимита, по умолчанию 0) + +**Окно редактирования секции** — добавляется: +- Поле «Макс. одновременных задач» (числовой ввод, 0 = без лимита) +- Список блоков (таблица/список с CRUD: добавить, редактировать, удалить) + - **Сортировка:** по `start_date` по убыванию (будущее сверху) + - Каждый блок — форма: title, start_date, end_date, block_concurrency (чекбокс: «Запретить пересечение задач») + - Валидация: end_date >= start_date, все поля заполнены + +--- + +### BR-5: Наследование + +- При появлении функционала копирования/дублирования диаграммы — настройки секций (лимит + блоки) наследуются + +--- + +## Технические требования + +### База данных + +#### Новая таблица: gantt_section_blocks + +```sql +CREATE TABLE gantt_section_blocks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + section_id UUID NOT NULL REFERENCES gantt_sections(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + block_concurrency BOOLEAN NOT NULL DEFAULT false, + created_by UUID NOT NULL REFERENCES users(id), + updated_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT chk_block_dates CHECK (end_date >= start_date) +); + +CREATE INDEX idx_gantt_section_blocks_section ON gantt_section_blocks(section_id); +CREATE INDEX idx_gantt_section_blocks_dates ON gantt_section_blocks(start_date, end_date); +``` + +#### Изменение таблицы: gantt_sections + +```sql +ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0; +``` + +--- + +### Backend (Go API) + +#### Этап 1: Миграция + Домен + +**Промт для агента:** + +> Добавь в проект новую таблицу `gantt_section_blocks` и колонку `max_concurrent_tasks` в `gantt_sections`. +> +> 1. Создай миграцию в `cmd/migrate/`: +> - Новая таблица `gantt_section_blocks` с полями: id (UUID PK), section_id (UUID FK → gantt_sections, ON DELETE CASCADE), title (VARCHAR 255 NOT NULL), start_date (DATE NOT NULL), end_date (DATE NOT NULL), block_concurrency (BOOLEAN DEFAULT false), created_by (UUID FK → users), updated_by (UUID FK → users), created_at, updated_at, deleted_at. CHECK constraint: end_date >= start_date. Индексы по section_id и (start_date, end_date). +> - ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0. +> 2. Создай доменную модель `GanttSectionBlock` в `internal/domain/gantt.go` со всеми полями таблицы. +> 3. Добавь поле `MaxConcurrentTasks int` в структуру `GanttSection`. +> 4. Обнови маппинг в relevant repository файлах, чтобы новое поле `max_concurrent_tasks` читалось/писалось. + +#### Этап 2: CRUD для GanttSectionBlocks + +**Промт для агента:** + +> Реализуй полный CRUD для сущности `GanttSectionBlock` по аналогии с существующими handlers/services/repositories в проекте. +> +> **Роуты (внутри группы секции):** +> - `GET /api/gantt-charts/:chartId/sections/:sectionId/blocks` — список блоков секции +> - `POST /api/gantt-charts/:chartId/sections/:sectionId/blocks` — создать блок +> - `PUT /api/gantt-charts/:chartId/sections/:sectionId/blocks/:blockId` — обновить блок +> - `DELETE /api/gantt-charts/:chartId/sections/:sectionId/blocks/:blockId` — удалить блок (soft delete) +> +> **Repository:** `internal/repository/gantt_section_block_repository.go` +> - `GetBySectionID(ctx, sectionID) ([]domain.GanttSectionBlock, error)` +> - `GetByID(ctx, id) (*domain.GanttSectionBlock, error)` +> - `Create(ctx, block) error` +> - `Update(ctx, block) error` +> - `Delete(ctx, id) error` — soft delete (set deleted_at) +> - Все запросы — raw SQL через pgx, как в остальных репозиториях проекта +> +> **Service:** `internal/service/gantt_section_block_service.go` +> - Бизнес-логика: валидация dates (end >= start), проверка прав доступа к секции +> - Inject repository +> +> **Handler:** `internal/handler/gantt_section_block_handler.go` +> - Swagger-аннотации для всех эндпоинтов +> - Валидация входных данных +> - Ошибки в формате проекта (GanttErrorResponse) +> +> **Модели запросов:** +> - Create: title (required), start_date (required), end_date (required), block_concurrency (bool, default false) +> - Update: title, start_date, end_date, block_concurrency — все опциональны + +#### Этап 3: Обновление настроек секции + +**Промт для агента:** + +> Обнови CRUD секций (gantt_sections) для поддержки нового поля `max_concurrent_tasks`. +> +> 1. Добавь `max_concurrent_tasks` в `GanttSectionCreateRequest` (опциональное, int ≥ 0, default 0) +> 2. Добавь `max_concurrent_tasks` в `GanttSectionUpdateRequest` (опциональное, int ≥ 0) +> 3. Обнови SQL-запросы в repository: INSERT и UPDATE должны включать `max_concurrent_tasks` +> 4. При GET секции (или списке секций) — возвращать `max_concurrent_tasks` в ответе +> 5. Валидация: значение должно быть ≥ 0 + +#### Этап 4: Включение блоков в ответ секций + +**Промт для агента:** + +> При запросе списка секций или одной секции включай связанные блоки в ответ. +> +> 1. Обнови `GET /api/gantt-charts/:chartId/sections/:sectionId` — добавить поле `blocks` с массивом GanttSectionBlock +> 2. Обнови `GET /api/gantt-charts/:chartId/sections` — добавить поле `blocks` для каждой секции +> 3. Блоки с `deleted_at IS NOT NULL` не возвращаются +> 4. Блоки сортируются по start_date по убыванию (будущее сверху) + +--- + +### Frontend (Angular) + +#### Этап FE-1: Модели + Сервис + +**Промт для агента:** + +> Добавь в проект поддержку ограничений секций Ганта. +> +> 1. В `pages/gantt/models/gantt-chart.model.ts` добавь: +> +> ```typescript +> export interface GanttSectionBlock { +> id: string; +> section_id: string; +> title: string; +> start_date: string; +> end_date: string; +> block_concurrency: boolean; +> created_by_user: UserInfo; +> updated_by_user: UserInfo; +> created_at: string; +> updated_at: string; +> } +> +> export interface GanttSectionBlockCreateRequest { +> title: string; +> start_date: string; +> end_date: string; +> block_concurrency?: boolean; +> } +> +> export interface GanttSectionBlockUpdateRequest { +> title?: string; +> start_date?: string; +> end_date?: string; +> block_concurrency?: boolean; +> } +> +> export interface ConflictCheck { +> has_conflicts: boolean; +> conflicts: ConflictItem[]; +> } +> +> export interface ConflictItem { +> type: 'concurrency' | 'block'; +> section_id: string; +> section_title: string; +> block_title?: string; +> start_date?: string; +> end_date?: string; +> message: string; +> dates?: string[]; +> } +> ``` +> +> 2. Добавь `max_concurrent_tasks: number` в интерфейс `GanttSection` +> 3. Добавь `blocks: GanttSectionBlock[]` в интерфейс `GanttSection` +> 4. Добавь `max_concurrent_tasks?: number` в `GanttSectionCreateRequest` и `GanttSectionUpdateRequest` +> +> 5. Создай сервис `pages/gantt/services/gantt-section-block.service.ts`: +> - Inject HttpClient +> - Методы: getBlocks(chartId, sectionId), createBlock(chartId, sectionId, data), updateBlock(chartId, sectionId, blockId, data), deleteBlock(chartId, sectionId, blockId) +> - Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService) + +#### Этап FE-2: Интеграция в модальные окна секции + +**Промт для агента:** + +> Интегрируй настройки ограничений в существующие модальные окна создания и редактирования секции Ганта. +> +> 1. Найди существующие компоненты модальных окон секции (создание/редактирование). +> +> **Окно создания секции** — добавить: +> - Числовой ввод: «Максимум одновременных задач» +> - Подсказка: «0 = без ограничений» +> - Валидация: число ≥ 0 +> - Значение по умолчанию: 0 +> - Отправляется в `GanttSectionCreateRequest` как `max_concurrent_tasks` +> +> **Окно редактирования секции** — добавить: +> - Числовой ввод: «Максимум одновременных задач» (аналогично созданию) +> - Секция «Блоки (запретные интервалы)»: +> - Таблица/список существующих блоков с колонками: Название, Начало, Конец, Блокировка (чекбокс/иконка), Действия (редактировать/удалить) +> - **Сортировка:** по `start_date` по убыванию (будущее сверху) +> - Кнопка «Добавить блок» — inline-форма: +> - title (текстовый ввод, обязательный) +> - start_date (datepicker, обязательный) +> - end_date (datepicker, обязательный) +> - block_concurrency (чекбокс: «Запретить пересечение задач») +> - Валидация: end_date >= start_date, все поля заполнены +> - Редактирование — inline или через ту же форму +> - Удаление — с подтверждением +> +> 2. При открытии окна редактирования — загрузить текущие настройки секции + блоки +> 3. При сохранении — обновить max_concurrent_tasks и сохранить изменения блоков +> 4. Инжектировать GanttChartService и GanttSectionBlockService + +#### Этап FE-3: Визуализация блоков на Ганте + +**Промт для агента:** + +> Отобрази блоки секций (GanttSectionBlock) на canvas-диаграмме Ганта. +> +> Проект использует Konva.js для рендеринга. Основной компонент: `gantt-chart-canvas`. +> +> 1. При загрузке данных диаграммы — извлечь блоки из каждой секции +> 2. В рендерере секций (canvas/timeline-renderer.ts или аналогичный) — для каждого блока: +> - Нарисовать заштрихованную область (diagonal hatch pattern) на **всю высоту секции** (все строки задач в этой секции) +> - Область ограничена по горизонтали: от start_date до end_date (в пикселях по шкале времени) +> - Цвет штриховки: полупрозрачный серый или configurable +> - Поверх штриховки — текст с названием блока (block.title) на подложке (прямоугольник с фоном, чтобы текст читался) +> 3. Блоки рендерятся **до** задач (z-index ниже), чтобы задачи были поверх +> 4. Блоки с `block_concurrency = true` и `false` визуально не отличаются (штриховка одинаковая) +> 5. При скролле/зуме — блоки пересчитываются как обычные элементы таймлайна + +#### Этап FE-4: Система Alert-ов + +**Промт для агента:** + +> Создай компонент для отображения Alert-ов о конфликтах на диаграмме Ганта. +> +> 1. Создай компонент `pages/gantt/components/gantt-conflict-alerts/` +> +> 2. Компонент размещается **над** canvas-диаграммой Ганта +> +> 3. Входные данные: массив `ConflictItem[]` (из модели) +> +> 4. Визуал: +> - Bootstrap Alert (warning) для каждого конфликта +> - Каждый Alert показывает message из ConflictItem +> - **Без кнопки закрытия** — Alert нельзя закрыть вручную +> - Alert исчезает автоматически, когда текущая дата прошла дату конфликта +> - Область **сворачиваемая**: заголовок «⚠️ Предупреждения (N)» — клик сворачивает/разворачивает список +> - По умолчанию — развёрнуто +> - При 0 конфликтов — область скрыта +> +> 5. Состояние хранится в сервисе или в компоненте диаграммы: +> - Добавление нового конфликта → area auto-expands +> - Alert удаляется из массива **автоматически**, когда текущая дата прошла дату конфликта +> - Проверка актуальности конфликтов — при каждой отрисовке / изменении данных +> - Не использовать toast — Alert-ы постоянные + +#### Этап FE-5: Проверка конфликтов при операциях с задачами + +**Промт для агента:** + +> Интегрируй проверку конфликтов во все операции с задачами на диаграмме Ганта. +> +> Проект использует interaction-handler.ts для drag/resize и формы для создания/редактирования. +> +> **Важно:** Проверка конфликтов — **только на фронтенде**, без вызова API. Используются загруженные данные секции (задачи, блоки, max_concurrent_tasks). +> +> 1. Вынеси логику проверки конфликтов `checkConflicts()` в отдельный метод в gantt-canvas.service.ts: +> - Принимает: section (GanttSection — содержит max_concurrent_tasks и blocks), tasks (GanttTask[] — задачи секции), task_id (nullable — исключить текущую задачу), start_date, end_date +> - Логика: +> a. Если section.max_concurrent_tasks > 0: посчитать кол-во задач (исключая task_id), пересекающихся с [start_date, end_date] на каждый день. Если в любой день count > max_concurrent_tasks — добавить конфликт concurrency +> b. Для каждого блока секции с block_concurrency = true, пересекающегося с [start_date, end_date] — добавить конфликт block +> - Возвращает ConflictCheck +> +> 2. После **каждой** успешной операции с задачей (создание, обновление, drag, resize): +> - Вызвать checkConflicts с параметрами задачи +> - Если has_conflicts = true — передать конфликты в GanttConflictAlertsComponent +> - Alert-ы показываются, действие **НЕ отменяется** +> +> 3. Точки интеграции: +> - **Создание задачи** (форма): после успешного POST → checkConflicts → show alerts +> - **Редактирование задачи** (форма): после успешного PUT → checkConflicts → show alerts +> - **Drag задачи** (interaction-handler.ts): после успешного обновления позиции → checkConflicts → show alerts +> - **Resize задачи** (interaction-handler.ts): после успешного обновления размера → checkConflicts → show alerts +> +> 4. При переключении диаграммы или секции — очищать Alert-ы +> 5. Alert-ы автоматически удаляются, когда текущая дата прошла дату конфликта — область скрывается, когда конфликтов не осталось + +--- + +## Порядок реализации + +| # | Этап | Сторона | Зависимости | +|---|------|---------|-------------| +| 1 | Миграция + Домен | Backend | — | +| 2 | CRUD блоков | Backend | Этап 1 | +| 3 | Обновление настроек секции | Backend | Этап 1 | +| 4 | Включение блоков в ответ секций | Backend | Этап 2 | +| 5 | Модели + Сервис | Frontend | — (можно параллельно с backend) | +| 6 | Интеграция в модальные окна | Frontend | Этапы 5 + backend 2,3 | +| 7 | Визуализация блоков на Ганте | Frontend | Этапы 5 + backend 4 | +| 8 | Система Alert-ов | Frontend | Этап 5 | +| 9 | Проверка конфликтов при операциях | Frontend | Этапы 5,7,8 | diff --git a/content/leadera/leadera-websocket-gantt.md b/content/leadera/leadera-websocket-gantt.md new file mode 100644 index 0000000..deefb81 --- /dev/null +++ b/content/leadera/leadera-websocket-gantt.md @@ -0,0 +1,746 @@ +--- +title: "Leadera — WebSocket для диаграмм Ганта" +date: 2026-04-07 +lastmod: 2026-04-07 +tags: ["leadera", "websocket", "realtime", "gantt", "architecture"] +weight: 110 +--- + +# WebSocket для диаграмм Ганта — realtime-совместное редактирование + +**Дата:** 07.04.2026 +**Статус:** Планирование +**Проект:** Leadera (dev — sand.a2v.space) + +--- + +## 1. Постановка задачи + +Диаграммы Ганта должны обновляться в реальном времени при совместной работе: если два или более пользователей редактируют один Гант, изменения каждого мгновенно отображаются у остальных — без перезагрузки страницы и ручного обновления. + +--- + +## 2. Архитектура + +``` +Angular frontend ←── WebSocket ──→ Go backend (Hub) ──→ PostgreSQL + │ │ + │ HTTP (мутации) │ Broadcast + └──→ REST API ──→ Service ──→ DB ──→ Hub ──→ все клиенты комнаты +``` + +**Ключевой принцип:** мутации данных идут через HTTP (REST), WebSocket используется **только для доставки уведомлений**. Сервер — источник истины, клиент не может обойти проверки через WS. + +### Комнаты + +Каждый открытый Гант — это «комната» с идентификатором `chartId`. При подключении клиент подписывается на комнату. Hub рассылает события всем подписчикам комнаты. + +--- + +## 3. Backend (Go) + +### 3.1. Зависимости + +```bash +go get github.com/gorilla/websocket +``` + +### 3.2. Новые файлы — `internal/ws/` + +#### `message.go` — типы сообщений + +```go +package ws + +import "time" + +// EventType — тип события +type EventType string + +const ( + // Данные + EventSectionCreated EventType = "section_created" + EventSectionUpdated EventType = "section_updated" + EventSectionDeleted EventType = "section_deleted" + EventSectionsReordered EventType = "sections_reordered" + EventTaskCreated EventType = "task_created" + EventTaskUpdated EventType = "task_updated" + EventTaskDeleted EventType = "task_deleted" + + // Присутствие + EventUserJoined EventType = "user_joined" + EventUserLeft EventType = "user_left" + + // Безопасность / доступ + EventAccessRevoked EventType = "access_revoked" + EventChartArchived EventType = "chart_archived" + EventRoleChanged EventType = "role_changed" + EventTokenExpired EventType = "token_expired" +) + +// Event — WS-событие, отправляемое клиентам +type Event struct { + Type EventType `json:"type"` + ChartID string `json:"chartId"` + UserID string `json:"userId,omitempty"` + Payload interface{} `json:"payload,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// IncomingMessage — сообщение от клиента (для ping/pong) +type IncomingMessage struct { + Type string `json:"type"` +} +``` + +#### `hub.go` — центральный брокер + +```go +package ws + +import ( + "sync" +) + +// Hub управляет комнатами и рассылкой событий +type Hub struct { + mu sync.RWMutex + rooms map[string]map[*Client]bool // chartId → клиенты +} + +func NewHub() *Hub { + return &Hub{ + rooms: make(map[string]map[*Client]bool), + } +} + +// Subscribe добавляет клиента в комнату +func (h *Hub) Subscribe(chartID string, client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.rooms[chartID] == nil { + h.rooms[chartID] = make(map[*Client]bool) + } + h.rooms[chartID][client] = true + + // Уведомить остальных: user_joined + h.broadcastExcept(chartID, client, Event{ + Type: EventUserJoined, + ChartID: chartID, + UserID: client.UserID, + Payload: map[string]string{"username": client.Username}, + Timestamp: time.Now(), + }) +} + +// Unsubscribe удаляет клиента из комнаты +func (h *Hub) Unsubscribe(chartID string, client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + if clients, ok := h.rooms[chartID]; ok { + delete(clients, client) + if len(clients) == 0 { + delete(h.rooms, chartID) + } + } + + // Уведомить остальных: user_left + h.broadcastExcept(chartID, client, Event{ + Type: EventUserLeft, + ChartID: chartID, + UserID: client.UserID, + Timestamp: time.Now(), + }) +} + +// Broadcast рассылает событие всем клиентам в комнате +func (h *Hub) Broadcast(chartID string, event Event) { + h.mu.RLock() + defer h.mu.RUnlock() + + if clients, ok := h.rooms[chartID]; ok { + for client := range clients { + client.Send(event) + } + } +} + +// SendToUser отправляет событие конкретному пользователю во всех его комнатах +func (h *Hub) SendToUser(userID string, event Event) { + h.mu.RLock() + defer h.mu.RUnlock() + + for _, clients := range h.rooms { + for client := range clients { + if client.UserID == userID { + client.Send(event) + } + } + } +} + +// broadcastExcept рассылает всем, кроме отправителя +func (h *Hub) broadcastExcept(chartID string, sender *Client, event Event) { + if clients, ok := h.rooms[chartID]; ok { + for client := range clients { + if client != sender { + client.Send(event) + } + } + } +} +``` + +#### `client.go` — обёртка WS-соединения + +```go +package ws + +import ( + "encoding/json" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 1024 +) + +type Client struct { + Hub *Hub + Conn *websocket.Conn + UserID string + Username string + ChartID string + SendCh chan Event + mu sync.Mutex +} + +func NewClient(hub *Hub, conn *websocket.Conn, userID, username, chartID string) *Client { + return &Client{ + Hub: hub, + Conn: conn, + UserID: userID, + Username: username, + ChartID: chartID, + SendCh: make(chan Event, 64), + } +} + +// ReadPump читает входящие сообщения (ping/pong) +func (c *Client) ReadPump() { + defer func() { + c.Hub.Unsubscribe(c.ChartID, c) + c.Conn.Close() + }() + + c.Conn.SetReadLimit(maxMessageSize) + c.Conn.SetReadDeadline(time.Now().Add(pongWait)) + c.Conn.SetPongHandler(func(string) error { + c.Conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, message, err := c.Conn.ReadMessage() + if err != nil { + break + } + // Клиент может слать только ping, данные игнорируем + _ = message + } +} + +// WritePump отправляет события клиенту +func (c *Client) WritePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.Conn.Close() + }() + + for { + select { + case event, ok := <-c.SendCh: + c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + data, _ := json.Marshal(event) + c.mu.Lock() + c.Conn.WriteMessage(websocket.TextMessage, data) + c.mu.Unlock() + + case <-ticker.C: + c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// Send ставит событие в очередь отправки +func (c *Client) Send(event Event) { + select { + case c.SendCh <- event: + default: + // Очередь полна — отключаем медленного клиента + close(c.SendCh) + } +} +``` + +### 3.3. WS endpoint — `internal/handlers/ws_handler.go` + +```go +package handlers + +import ( + "net/http" + "time" + + "app-leadera-api/internal/middleware" + "app-leadera-api/internal/ws" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, // nginx проксирует +} + +type WSHandler struct { + hub *ws.Hub + ganttMW *middleware.GanttMiddleware +} + +func NewWSHandler(hub *ws.Hub, ganttMW *middleware.GanttMiddleware) *WSHandler { + return &WSHandler{hub: hub, ganttMW: ganttMW} +} + +func (h *WSHandler) HandleGanttWS(c *gin.Context) { + // 1. Получить пользователя из контекста (auth middleware уже отработал) + userID, exists := c.Get("userID") + if !exists { + c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"}) + return + } + + // 2. Проверить доступ к chartId (gantt middleware) + chartID := c.Param("chartId") + if _, err := uuid.Parse(chartID); err != nil { + c.AbortWithStatusJSON(400, gin.H{"error": "invalid chart id"}) + return + } + // TODO: проверить через ganttMW что userID имеет доступ к этому chart + + // 3. Upgrade до WebSocket + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + + // 4. Создать клиент и подписать на комнату + client := ws.NewClient( + h.hub, conn, + userID.(string), + c.GetString("username"), + chartID, + ) + h.hub.Subscribe(chartID, client) + + // 5. Запустить pump'ы + go client.WritePump() + go client.ReadPump() +} +``` + +### 3.4. Регистрация маршрута + +В `cmd/server/main.go` или где регистрируются routes: + +```go +// После создания hub +wsHub := ws.NewHub() + +// WS endpoint +wsHandler := handlers.NewWSHandler(wsHub, ganttMW) +api.GET("/ws/gantt/:chartId", authMW.RequireAuth(), wsHandler.HandleGanttWS) + +// Передать hub в ganttHandler для broadcast'ов +ganttHandler := handlers.NewGanttHandler(ganttService, sectionService, taskService, blockService, wsHub) +``` + +### 3.5. Интеграция broadcast в handlers + +В каждый write-handler — одна строка после успешной операции: + +```go +// Пример: после успешного UpdateTask +h.hub.Broadcast(chartID, ws.Event{ + Type: ws.EventTaskUpdated, + ChartID: chartID, + UserID: userID, + Payload: updatedTask, + Timestamp: time.Now(), +}) +``` + +Точки интеграции в `gantt_handler.go`: +- `CreateGanttChart` → не нужен (пользователь только что создал, он один) +- `UpdateGanttChart` / `ArchiveGanttChart` → `chart_archived` +- `CreateSection` → `section_created` +- `UpdateSection` → `section_updated` +- `DeleteSection` → `section_deleted` +- `ReorderSections` → `sections_reordered` +- `CreateTask` → `task_created` +- `UpdateTask` → `task_updated` +- `DeleteTask` → `task_deleted` + +### 3.6. Интеграция событий безопасности + +В соответствующих service-методах: + +```go +// При исключении из space (SpaceService.RemoveMember) +wsHub.SendToUser(removedUserID, ws.Event{ + Type: ws.EventAccessRevoked, + Payload: map[string]string{"spaceId": spaceID}, +}) + +// При архивации ганта +wsHub.Broadcast(chartID, ws.Event{ + Type: ws.EventChartArchived, + ChartID: chartID, +}) + +// При изменении роли +wsHub.SendToUser(userID, ws.Event{ + Type: ws.EventRoleChanged, + Payload: map[string]string{"role": newRole}, +}) +``` + +--- + +## 4. Frontend (Angular) + +### 4.1. WebSocket сервис — `gantt-ws.service.ts` + +```typescript +// src/app/pages/gantt/services/gantt-ws.service.ts + +import { Injectable, inject, OnDestroy } from '@angular/core'; +import { Subject, Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { SpaceStorageService } from '../../../core/services/space-storage.service'; +import { AuthService } from '../../../core/auth/services/auth.service'; + +export interface WSEvent { + type: string; + chartId?: string; + userId?: string; + payload?: any; + timestamp?: string; +} + +@Injectable({ providedIn: 'root' }) +export class GanttWSService implements OnDestroy { + private spaceStorage = inject(SpaceStorageService); + private auth = inject(AuthService); + + private socket: WebSocket | null = null; + private eventSubject = new Subject(); + private reconnectTimer: any; + private chartId: string | null = null; + + /** Observable для подписки на события */ + get events$(): Observable { + return this.eventSubject.asObservable(); + } + + /** Подключиться к комнате ганта */ + connect(chartId: string): void { + this.disconnect(); + this.chartId = chartId; + + const token = this.auth.getAccessToken(); + const wsUrl = this.buildWSUrl(chartId, token); + + this.socket = new WebSocket(wsUrl); + + this.socket.onopen = () => { + console.log(`[WS] Connected to gantt ${chartId}`); + }; + + this.socket.onmessage = (msg) => { + try { + const event: WSEvent = JSON.parse(msg.data); + this.eventSubject.next(event); + } catch (e) { + console.error('[WS] Parse error', e); + } + }; + + this.socket.onclose = (event) => { + console.log(`[WS] Closed: ${event.code}`); + if (event.code !== 1000) { + this.scheduleReconnect(); + } + }; + + this.socket.onerror = () => { + this.socket?.close(); + }; + } + + /** Отключиться */ + disconnect(): void { + clearTimeout(this.reconnectTimer); + if (this.socket) { + this.socket.close(1000, 'manual'); + this.socket = null; + } + this.chartId = null; + } + + ngOnDestroy(): void { + this.disconnect(); + } + + private buildWSUrl(chartId: string, token: string): string { + const base = environment.wsUrl || + location.protocol.replace('http', 'ws') + '//' + location.host; + return `${base}/api/v1/ws/gantt/${chartId}?token=${token}`; + } + + private scheduleReconnect(): void { + if (!this.chartId) return; + this.reconnectTimer = setTimeout(() => { + console.log('[WS] Reconnecting...'); + if (this.chartId) this.connect(this.chartId); + }, 3000); + } +} +``` + +### 4.2. Интеграция в gantt-chart-canvas компонент + +```typescript +// В gantt-chart-canvas.component.ts + +export class GanttChartCanvasComponent implements OnInit, OnDestroy { + private ws = inject(GanttWSService); + private onlineUsers: Set = new Set(); + private wsSubscription: Subscription | null = null; + + ngOnInit(): void { + // Подключиться к WS при загрузке ганта + this.ws.connect(this.chartId); + this.wsSubscription = this.ws.events$.subscribe(event => { + this.handleWSEvent(event); + }); + } + + ngOnDestroy(): void { + this.wsSubscription?.unsubscribe(); + this.ws.disconnect(); + } + + private handleWSEvent(event: WSEvent): void { + // Если это событие от текущего пользователя — пропускаем + // (мы уже обновили UI оптимистично или после HTTP-ответа) + if (event.userId === this.currentUserId) return; + + switch (event.type) { + case 'section_created': + this.addSectionLocal(event.payload); + this.redrawCanvas(); + break; + + case 'section_updated': + this.updateSectionLocal(event.payload); + this.redrawCanvas(); + break; + + case 'section_deleted': + this.removeSectionLocal(event.payload.id); + this.redrawCanvas(); + break; + + case 'sections_reordered': + this.reorderSectionsLocal(event.payload.ordered_ids); + this.redrawCanvas(); + break; + + case 'task_created': + this.addTaskLocal(event.payload); + this.redrawCanvas(); + break; + + case 'task_updated': + this.updateTaskLocal(event.payload); + this.redrawCanvas(); + break; + + case 'task_deleted': + this.removeTaskLocal(event.payload.id); + this.redrawCanvas(); + break; + + case 'user_joined': + this.onlineUsers.add(event.userId!); + this.showOnlineIndicator(); + break; + + case 'user_left': + this.onlineUsers.delete(event.userId!); + this.showOnlineIndicator(); + break; + + // === Безопасность === + case 'access_revoked': + this.router.navigate(['/gantt'], { + state: { alert: 'Доступ к пространству был отозван' } + }); + break; + + case 'chart_archived': + this.router.navigate(['/gantt'], { + state: { alert: 'Диаграмма была заархивирована' } + }); + break; + + case 'role_changed': + this.currentRole = event.payload.role; + this.updateEditableState(); + break; + + case 'token_expired': + this.router.navigate(['/auth/login']); + break; + } + } + + private redrawCanvas(): void { + // Перерисовать Konva canvas с обновлёнными данными + } +} +``` + +### 4.3. Индикатор онлайн-пользователей + +Добавить в шаблон canvas-компонента: + +```html + +
+ + {{ onlineUsers.size }} онлайн +
+``` + +--- + +## 5. Безопасность + +### 5.1. Авторизация при подключении + +| Этап | Проверка | +|------|----------| +| WS Handshake | JWT в query-параметре `?token=...` | +| Подписка на комнату | Проверка membership в space | +| Каждое broadcast-событие | Hub знает userId каждого клиента | + +### 5.2. Сценарии отзыва доступа + +| Событие | Триггер | Действие на клиенте | +|---------|---------|---------------------| +| `access_revoked` | Исключение из space | Редирект на список гантов | +| `chart_archived` | Архивация ганта | Редирект на список гантов | +| `role_changed` | Смена роли (editor→viewer) | Отключить редактирование | +| `token_expired` | Токен протух | Редирект на логин | + +### 5.3. Защита от злоупотреблений + +| Угроза | Мера | +|--------|------| +| Спам WS-сообщений | Rate limit: 30 msg/min на клиента, `ReadLimit` в gorilla/websocket | +| Подключение без прав | Проверка space membership при handshake | +| Истёкший токен | Ping/pong + периодическая проверка JWT (раз в 5 мин) | +| XSS через WS | Payload — JSON, sanitize имён на frontend | +| Мутации через WS | WS **только для чтения** (уведомления). Мутации — через HTTP API с полной валидацией | + +### 5.4. Nginx конфигурация + +В конфиг `sand.a2v.space` добавить: + +```nginx +# WebSocket проксирование +location /api/v1/ws/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; # 24h для long-lived WS + proxy_send_timeout 86400s; +} +``` + +--- + +## 6. Оценка трудозатрат + +| Компонент | Строки кода | Время | +|-----------|-------------|-------| +| `internal/ws/` (hub, client, message) | ~200 | 2-3 часа | +| `ws_handler.go` (endpoint) | ~60 | 30 мин | +| Интеграция broadcast в handlers | ~20 (по строке на endpoint) | 30 мин | +| События безопасности в services | ~30 | 30 мин | +| `gantt-ws.service.ts` (Angular) | ~80 | 1 час | +| Интеграция в canvas-компонент | ~80 | 1-2 часа | +| Индикатор онлайн | ~30 | 30 мин | +| Nginx конфигурация | ~10 | 10 мин | +| Тестирование | — | 2-3 часа | +| **Итого** | **~510 строк** | **~1-2 дня** | + +--- + +## 7. Порядок реализации + +1. **Backend: `internal/ws/`** — hub, client, message types +2. **Backend: WS endpoint** — handler + route +3. **Backend: Broadcast в handlers** — интеграция в существующие CRUD-эндпоинты +4. **Backend: События безопасности** — access_revoked, chart_archived, role_changed +5. **Nginx: WS location** — проксирование WebSocket +6. **Frontend: WS service** — подключение, реконнект, парсинг событий +7. **Frontend: Интеграция в canvas** — обработка событий, обновление Konva +8. **Frontend: Индикатор онлайн** — визуальная обратная связь +9. **Тестирование** — многопользовательский сценарий, отзыв прав, обрывы связи + +--- + +## 8. Дальнейшие улучшения (после MVP) + +- **Cursor других пользователей** — показывать позицию курсора на таймлайне +- **Блокировка элемента** — «кто-то редактирует эту задачу» (optimistic locking через WS) +- **Чат в ганте** — комментарии к задачам прямо на канвасе +- **History/undo** — откат изменений с визуальной историей +- **Уведомления** — push-уведомления при @mention в комментариях к задачам diff --git a/hugo.toml b/hugo.toml index dce1d6d..e3daea0 100644 --- a/hugo.toml +++ b/hugo.toml @@ -30,18 +30,22 @@ canonifyURLs = false name = "Планы" url = "/plans/" weight = 3 + [[menu.main]] + name = "Leadera" + url = "/leadera/" + weight = 4 [[menu.main]] name = "Инструкции" url = "/guides/" - weight = 4 + weight = 5 [[menu.main]] name = "Креды" url = "/creds/" - weight = 5 + weight = 6 [[menu.main]] name = "Личный сайт" url = "/personal-site/" - weight = 6 + weight = 7 [outputs] home = ["HTML", "JSON"] diff --git a/public/categories/index.html b/public/categories/index.html index 5171dd6..71e6338 100644 --- a/public/categories/index.html +++ b/public/categories/index.html @@ -38,6 +38,10 @@ Планы + + @@ -94,6 +98,8 @@ + + diff --git a/public/creds/docs-a2v-space-auth/index.html b/public/creds/docs-a2v-space-auth/index.html index 29f29ce..b5ec406 100644 --- a/public/creds/docs-a2v-space-auth/index.html +++ b/public/creds/docs-a2v-space-auth/index.html @@ -38,6 +38,10 @@ Планы + + @@ -135,6 +139,15 @@

docs.a2v.space Basic Auth

+
+ + 📁 ~/Projects/docs-a2v/content/creds/docs-a2v-space-auth.md + + +
+

Учетные данные для доступа к docs.a2v.space

diff --git a/public/creds/eversolo-companion-postgres/index.html b/public/creds/eversolo-companion-postgres/index.html index 8200609..83bf566 100644 --- a/public/creds/eversolo-companion-postgres/index.html +++ b/public/creds/eversolo-companion-postgres/index.html @@ -38,6 +38,10 @@ Планы + + @@ -135,6 +139,15 @@

Eversolo Companion PostgreSQL

+
+ + 📁 ~/Projects/docs-a2v/content/creds/eversolo-companion-postgres.md + + +
+

Учетные данные для базы данных Eversolo Companion

diff --git a/public/creds/index.html b/public/creds/index.html index d3ced27..94981cf 100644 --- a/public/creds/index.html +++ b/public/creds/index.html @@ -38,6 +38,10 @@ Планы + + @@ -142,97 +146,23 @@
-

docs.a2v.space Basic Auth

+

VPS reg.ru

-

Учетные данные для доступа к docs.a2v.space

+

VPS хостинг (реверс-прокси)

+

Сервис: VPS

+ -
- QmFzaWMgQXV0aGVudGljYXRpb24gVXNlcm5hbWU6IGFnYWx0c292YXYgUGFzc3dvcmQ6IE1hZ2FtZXQ3ODchINCa0L7QvdGE0LjQs9GD0YDQsNGG0LjRjyBOZ2lueCBzZXJ2ZXIgeyBsaXN0ZW4gODA5MTsgc2VydmVyX25hbWUgZG9jcy5hMnYuc3BhY2U7IGF1dGhfYmFzaWMgJiMzNDtSZXN0cmljdGVkIEFjY2VzcyYjMzQ7OyBhdXRoX2Jhc2ljX3VzZXJfZmlsZSAvZXRjL25naW54Ly5odHBhc3N3ZDsgfSDQlNC+0YHRgtGD0L8gVVJMOiBodHRwczovL2RvY3MuYTJ2LnNwYWNlICjRh9C10YDQtdC3IHJldmVyc2UgcHJveHkpIExvY2FsOiBodHRwOi8vbG9jYWxob3N0OjgwOTEgKNC00LvRjyDRgNCw0LfRgNCw0LHQvtGC0LrQuCkgQXV0aDog0LLRgdC10LPQtNCwINGC0YDQtdCx0YPQtdGC0YHRjyDQnNCw0YHRgtC10YAt0L/QsNGA0L7Qu9GMINC00LvRjyDRiNC40YTRgNC+0LLQsNC90LjRjyDQktCw0LbQvdC+OiDQn9Cw0YDQvtC70Ywg0LTQu9GPINGA0LDRgdGI0LjRhNGA0L7QstC60Lgg0YTQsNC50LvQvtCyINCyINGA0LDQt9C00LXQu9C1IGNyZWRzLwpNYXN0ZXIgUGFzc3dvcmQ6IFBvcG9sb2thMTYhINCo0LjRhNGA0L7QstCw0L3QuNC1INCQ0LvQs9C+0YDQuNGC0Lw6IEFFUy0yNTYtR0NNINCl0YDQsNC90LjQu9C40YnQtTogY2xpZW50LXNpZGUgZW5jcnlwdGlvbiDQpNCw0LnQu9GLINGI0LjRhNGA0YPRjtGC0YHRjyDQv9GA0Lgg0YHQvtGF0YDQsNC90LXQvdC40Lgg0KDQsNGB0YjQuNGE0YDQvtCy0YvQstCw0Y7RgtGB0Y8g0L/RgNC4INGH0YLQtdC90LjQuCA= +
+ CklwWVhMTXRnQ0ZsR1E1NTBEWkNManEzN1Zzbk5xTDA4Um4wM2lSUzdrUTdvekJaTnNpWnlTRnpEQXZZaDR4SUh5dFJEeTBTejBLR1VYZWtyMFMrY05pSnNtdFo4a2owUGQwSmhldmh4VE1aRjJraEZ2cy9MRldvVEw1WndBSjM0eS9xZkx3MmdhV1g5MFZaRHUwd08rS2VBUWVYaHJXbW9sLzJ6VWxmV1gxQWloeVlxRDJ2UnoraDJGY2pEOHViWU9YRzM1UHlYN09sVjFIUFhnRHY1WC9iRk5yeHpQYzg2N1VPd0I0YUd4WTlhCgoK
- -
-
- -
-
-
-

Eversolo Companion PostgreSQL

- -

Учетные данные для базы данных Eversolo Companion

- - - -
- RG9ja2VyIENvbXBvc2Ug0LrRgNC10LTRiyAofi9Qcm9qZWN0cy9ldmVyc29sby1jb21wYW5pb24vZG9ja2VyLWNvbXBvc2UueW1sKSBzZXJ2aWNlczogcG9zdGdyZXM6IGVudmlyb25tZW50OiBQT1NUR1JFU19VU0VSOiBwb3N0Z3JlcyBQT1NUR1JFU19QQVNTV09SRDogcGFzc3dvcmQgUE9TVEdSRVNfREI6IGV2ZXJzb2xvINCb0L7QutCw0LvRjNC90YvQtSDQutGA0LXQtNGLICguZW52INGE0LDQudC7KSBQT1NUR1JFU19VU0VSPXBvc3RncmVzIFBPU1RHUkVTX1BBU1NXT1JEPXBhc3N3b3JkIFBPU1RHUkVTX0RCPWV2ZXJzb2xvINCl0L7RgdGC0LjQvdCzIEhvc3Q6IGxvY2FsaG9zdCBQb3J0OiA1NDMzIERhdGFiYXNlOiBldmVyc29sbyDQkdC10LfQvtC/0LDRgdC90L7RgdGC0Ywg0JjRgdC/0L7Qu9GM0LfRg9C50YLQtSDRgNCw0LfQvdGL0LUg0L/QsNGA0L7Qu9C4INC00LvRjyBwcm9kdWN0aW9uINCS0LrQu9GO0YfQuNGC0LUgU1NMINC00LvRjyDRg9C00LDQu9C10L3QvdC+0LPQviDQv9C+0LTQutC70Y7Rh9C10L3QuNGPINCg0LXQs9GD0LvRj9GA0L3QviDQtNC10LvQsNC50YLQtSDQsdGN0LrQsNC/0Ysg -
- - - - -
-
-
- -
-
-
-

Mac Studio — leadera-api БД (docker-compose)

- -

PostgreSQL (docker-compose) для Leadera

- - -

Сервис: PostgreSQL

- - -
- Ck5ZVks0UEVHd21PSG1BbkdrOElBVWMrbHlGSnRuRUF2WDZaZGRCcm8vSUxSV0hPWEJHWXl3Z2dsRnZRWjE4QkxETGI1RHVBOEdQQUlkQi9Md1RIUlhZTzBLc210bFpRVldHRWVlWGxYQW9FWHBhNXRoZW9PUWhlYmtXNkxtaUt5TkdBVlcyejlySzNuejZObnp0WWRuNDlMdkdNanJIdVI0SXdDTGJsL3BZYTk2Q1BOUm54R0RGbGR1Rmk4bE1Tc3BNMHJCRmkvYlpoTkdoenkzNlZpQ2wrYUtyNVA5Y2xlMFp2TThFOGF2LzZjNUpqZ0R0ODhRRkkzVllkVGFUQ0UwK3VIc1hsbkpON1FnZy9LSDdrRAoKCg== -
- - - - -
-
-
- -
-
-
-

Mac Studio — leadera-api БД (локальная)

- -

PostgreSQL (Docker) для Leadera

- - -

Сервис: PostgreSQL

- - -
- CnA3SXc3WTN6Ymoxa0xCY2JPWEdvTzI3b0R3Rjg1QWh4Qlc3c2FUNTM4aHhpMG9nSnB5RmVNNDlHRHVMSHp4dXlrbnZkTkMvcngzdGI0QUZuR2tDZUhTcnJDbWorVks4Wnh0N3VqZXVqVkpTUnVPZWVVbHNxa0hkbG1haVhsMktRa25ySXNxVkNpM1RubjBWNXFjRnh2bysxS3RjK3N1ckY2YkJwV3JUSHpXVjVtTHRXVmZENkM2a3Z2UC9VRmxkRkJENnYrRlFzNzl5Zm9PZS9qL2JVcnRwdDZ2NVZuZGJiQm1oVUFRY3kxQlYycnBYeE8zNzNkVGNMUTZjb1JXa3QvUkZvCgoK -
- - - -
@@ -268,23 +198,97 @@
-

VPS reg.ru

+

Mac Studio — leadera-api БД (локальная)

-

VPS хостинг (реверс-прокси)

+

PostgreSQL (Docker) для Leadera

-

Сервис: VPS

+

Сервис: PostgreSQL

-
- CklwWVhMTXRnQ0ZsR1E1NTBEWkNManEzN1Zzbk5xTDA4Um4wM2lSUzdrUTdvekJaTnNpWnlTRnpEQXZZaDR4SUh5dFJEeTBTejBLR1VYZWtyMFMrY05pSnNtdFo4a2owUGQwSmhldmh4VE1aRjJraEZ2cy9MRldvVEw1WndBSjM0eS9xZkx3MmdhV1g5MFZaRHUwd08rS2VBUWVYaHJXbW9sLzJ6VWxmV1gxQWloeVlxRDJ2UnoraDJGY2pEOHViWU9YRzM1UHlYN09sVjFIUFhnRHY1WC9iRk5yeHpQYzg2N1VPd0I0YUd4WTlhCgoK +
+ CnA3SXc3WTN6Ymoxa0xCY2JPWEdvTzI3b0R3Rjg1QWh4Qlc3c2FUNTM4aHhpMG9nSnB5RmVNNDlHRHVMSHp4dXlrbnZkTkMvcngzdGI0QUZuR2tDZUhTcnJDbWorVks4Wnh0N3VqZXVqVkpTUnVPZWVVbHNxa0hkbG1haVhsMktRa25ySXNxVkNpM1RubjBWNXFjRnh2bysxS3RjK3N1ckY2YkJwV3JUSHpXVjVtTHRXVmZENkM2a3Z2UC9VRmxkRkJENnYrRlFzNzl5Zm9PZS9qL2JVcnRwdDZ2NVZuZGJiQm1oVUFRY3kxQlYycnBYeE8zNzNkVGNMUTZjb1JXa3QvUkZvCgoK
- +
+
+ +
+
+
+

Mac Studio — leadera-api БД (docker-compose)

+ +

PostgreSQL (docker-compose) для Leadera

+ + +

Сервис: PostgreSQL

+ + +
+ Ck5ZVks0UEVHd21PSG1BbkdrOElBVWMrbHlGSnRuRUF2WDZaZGRCcm8vSUxSV0hPWEJHWXl3Z2dsRnZRWjE4QkxETGI1RHVBOEdQQUlkQi9Md1RIUlhZTzBLc210bFpRVldHRWVlWGxYQW9FWHBhNXRoZW9PUWhlYmtXNkxtaUt5TkdBVlcyejlySzNuejZObnp0WWRuNDlMdkdNanJIdVI0SXdDTGJsL3BZYTk2Q1BOUm54R0RGbGR1Rmk4bE1Tc3BNMHJCRmkvYlpoTkdoenkzNlZpQ2wrYUtyNVA5Y2xlMFp2TThFOGF2LzZjNUpqZ0R0ODhRRkkzVllkVGFUQ0UwK3VIc1hsbkpON1FnZy9LSDdrRAoKCg== +
+ + + + +
+
+
+ +
+
+
+

Eversolo Companion PostgreSQL

+ +

Учетные данные для базы данных Eversolo Companion

+ + + +
+ RG9ja2VyIENvbXBvc2Ug0LrRgNC10LTRiyAofi9Qcm9qZWN0cy9ldmVyc29sby1jb21wYW5pb24vZG9ja2VyLWNvbXBvc2UueW1sKSBzZXJ2aWNlczogcG9zdGdyZXM6IGVudmlyb25tZW50OiBQT1NUR1JFU19VU0VSOiBwb3N0Z3JlcyBQT1NUR1JFU19QQVNTV09SRDogcGFzc3dvcmQgUE9TVEdSRVNfREI6IGV2ZXJzb2xvINCb0L7QutCw0LvRjNC90YvQtSDQutGA0LXQtNGLICguZW52INGE0LDQudC7KSBQT1NUR1JFU19VU0VSPXBvc3RncmVzIFBPU1RHUkVTX1BBU1NXT1JEPXBhc3N3b3JkIFBPU1RHUkVTX0RCPWV2ZXJzb2xvINCl0L7RgdGC0LjQvdCzIEhvc3Q6IGxvY2FsaG9zdCBQb3J0OiA1NDMzIERhdGFiYXNlOiBldmVyc29sbyDQkdC10LfQvtC/0LDRgdC90L7RgdGC0Ywg0JjRgdC/0L7Qu9GM0LfRg9C50YLQtSDRgNCw0LfQvdGL0LUg0L/QsNGA0L7Qu9C4INC00LvRjyBwcm9kdWN0aW9uINCS0LrQu9GO0YfQuNGC0LUgU1NMINC00LvRjyDRg9C00LDQu9C10L3QvdC+0LPQviDQv9C+0LTQutC70Y7Rh9C10L3QuNGPINCg0LXQs9GD0LvRj9GA0L3QviDQtNC10LvQsNC50YLQtSDQsdGN0LrQsNC/0Ysg +
+ + + + +
+
+
+ +
+
+
+

docs.a2v.space Basic Auth

+ +

Учетные данные для доступа к docs.a2v.space

+ + + +
+ QmFzaWMgQXV0aGVudGljYXRpb24gVXNlcm5hbWU6IGFnYWx0c292YXYgUGFzc3dvcmQ6IE1hZ2FtZXQ3ODchINCa0L7QvdGE0LjQs9GD0YDQsNGG0LjRjyBOZ2lueCBzZXJ2ZXIgeyBsaXN0ZW4gODA5MTsgc2VydmVyX25hbWUgZG9jcy5hMnYuc3BhY2U7IGF1dGhfYmFzaWMgJiMzNDtSZXN0cmljdGVkIEFjY2VzcyYjMzQ7OyBhdXRoX2Jhc2ljX3VzZXJfZmlsZSAvZXRjL25naW54Ly5odHBhc3N3ZDsgfSDQlNC+0YHRgtGD0L8gVVJMOiBodHRwczovL2RvY3MuYTJ2LnNwYWNlICjRh9C10YDQtdC3IHJldmVyc2UgcHJveHkpIExvY2FsOiBodHRwOi8vbG9jYWxob3N0OjgwOTEgKNC00LvRjyDRgNCw0LfRgNCw0LHQvtGC0LrQuCkgQXV0aDog0LLRgdC10LPQtNCwINGC0YDQtdCx0YPQtdGC0YHRjyDQnNCw0YHRgtC10YAt0L/QsNGA0L7Qu9GMINC00LvRjyDRiNC40YTRgNC+0LLQsNC90LjRjyDQktCw0LbQvdC+OiDQn9Cw0YDQvtC70Ywg0LTQu9GPINGA0LDRgdGI0LjRhNGA0L7QstC60Lgg0YTQsNC50LvQvtCyINCyINGA0LDQt9C00LXQu9C1IGNyZWRzLwpNYXN0ZXIgUGFzc3dvcmQ6IFBvcG9sb2thMTYhINCo0LjRhNGA0L7QstCw0L3QuNC1INCQ0LvQs9C+0YDQuNGC0Lw6IEFFUy0yNTYtR0NNINCl0YDQsNC90LjQu9C40YnQtTogY2xpZW50LXNpZGUgZW5jcnlwdGlvbiDQpNCw0LnQu9GLINGI0LjRhNGA0YPRjtGC0YHRjyDQv9GA0Lgg0YHQvtGF0YDQsNC90LXQvdC40Lgg0KDQsNGB0YjQuNGE0YDQvtCy0YvQstCw0Y7RgtGB0Y8g0L/RgNC4INGH0YLQtdC90LjQuCA= +
+ + + +
diff --git a/public/creds/leadera-db-docker/index.html b/public/creds/leadera-db-docker/index.html index 6db90df..f6c3df7 100644 --- a/public/creds/leadera-db-docker/index.html +++ b/public/creds/leadera-db-docker/index.html @@ -38,6 +38,10 @@ Планы + + @@ -135,6 +139,15 @@

Mac Studio — leadera-api БД (docker-compose)

+
+ + 📁 ~/Projects/docs-a2v/content/creds/leadera-db-docker.md + + +
+

PostgreSQL (docker-compose) для Leadera

diff --git a/public/creds/leadera-db-local/index.html b/public/creds/leadera-db-local/index.html index 29d97c9..e6ab620 100644 --- a/public/creds/leadera-db-local/index.html +++ b/public/creds/leadera-db-local/index.html @@ -38,6 +38,10 @@ Планы + + @@ -135,6 +139,15 @@

Mac Studio — leadera-api БД (локальная)

+
+ + 📁 ~/Projects/docs-a2v/content/creds/leadera-db-local.md + + +
+

PostgreSQL (Docker) для Leadera

diff --git a/public/creds/vpn-vps/index.html b/public/creds/vpn-vps/index.html index 69eb748..14de86b 100644 --- a/public/creds/vpn-vps/index.html +++ b/public/creds/vpn-vps/index.html @@ -38,6 +38,10 @@ Планы + + @@ -135,6 +139,15 @@

VPN VPS

+
+ + 📁 ~/Projects/docs-a2v/content/creds/vpn-vps.md + + +
+

VPN сервер (Amnezia)

diff --git a/public/creds/vps-reg-ru/index.html b/public/creds/vps-reg-ru/index.html index 53183de..600c6a4 100644 --- a/public/creds/vps-reg-ru/index.html +++ b/public/creds/vps-reg-ru/index.html @@ -38,6 +38,10 @@ Планы + + @@ -135,6 +139,15 @@

VPS reg.ru

+
+ + 📁 ~/Projects/docs-a2v/content/creds/vps-reg-ru.md + + +
+

VPS хостинг (реверс-прокси)

diff --git a/public/css/custom.css b/public/css/custom.css index 6f13908..632b8b7 100644 --- a/public/css/custom.css +++ b/public/css/custom.css @@ -270,6 +270,27 @@ body { font-family: monospace; } +/* Source path */ +.source-path { + background-color: var(--warm-bg); + border: 1px solid #e0d8cc; + border-radius: 4px; + padding: 0.4rem 0.75rem; + display: inline-block; +} +.source-path code { + font-size: 0.82em; + color: var(--warm-gray); +} +.source-path .btn-outline-secondary { + font-size: 0.75rem; + border-color: #d0c8bc; +} +.source-path .btn-outline-secondary:hover { + background-color: var(--warm-primary); + border-color: var(--warm-primary); +} + /* Article styles */ .article-content { background-color: var(--warm-white); diff --git a/public/diary/2026-03-29/index.html b/public/diary/2026-03-29/index.html index 89b370e..b24d19f 100644 --- a/public/diary/2026-03-29/index.html +++ b/public/diary/2026-03-29/index.html @@ -38,6 +38,10 @@ Планы + + @@ -205,6 +209,15 @@

29 марта 2026

29.03.2026

+
+ + 📁 ~/Projects/docs-a2v/content/diary/2026-03-29.md + + +
+

Timeline

    diff --git a/public/diary/2026-03-30/index.html b/public/diary/2026-03-30/index.html index 5745446..781158e 100644 --- a/public/diary/2026-03-30/index.html +++ b/public/diary/2026-03-30/index.html @@ -38,6 +38,10 @@ Планы + + @@ -205,6 +209,15 @@

    30 марта 2026

    30.03.2026

    +
    + + 📁 ~/Projects/docs-a2v/content/diary/2026-03-30.md + + +
    +

    Timeline

      diff --git a/public/diary/2026-03-31/index.html b/public/diary/2026-03-31/index.html index 301aae5..354bdbd 100644 --- a/public/diary/2026-03-31/index.html +++ b/public/diary/2026-03-31/index.html @@ -38,6 +38,10 @@ Планы + + @@ -205,6 +209,15 @@

      31 марта 2026

      31.03.2026

      +
      + + 📁 ~/Projects/docs-a2v/content/diary/2026-03-31.md + + +
      +

      Timeline

        diff --git a/public/diary/index.html b/public/diary/index.html index 059e695..149b240 100644 --- a/public/diary/index.html +++ b/public/diary/index.html @@ -38,6 +38,10 @@ Планы + + diff --git a/public/guides/claw-a2v-space/index.html b/public/guides/claw-a2v-space/index.html index 9bd5075..3d29280 100644 --- a/public/guides/claw-a2v-space/index.html +++ b/public/guides/claw-a2v-space/index.html @@ -38,6 +38,10 @@ Планы + + @@ -110,16 +114,6 @@ - - - - - - - - - - @@ -166,14 +160,6 @@
        Теги
      diff --git a/public/tags/websocket/index.html b/public/tags/websocket/index.html new file mode 100644 index 0000000..e9c4edf --- /dev/null +++ b/public/tags/websocket/index.html @@ -0,0 +1,152 @@ + + + + + + Websocket — docs.a2v.space + + + + + + + + + + + + + +
      +
      + + + + +
      + +
      +

      Websocket

      + + + +
      + +
      +
      +
      + +
      +
      +

      © 2026 docs.a2v.space

      +
      +
      + + + + + + + + + + + + + + + diff --git a/public/tags/websocket/index.xml b/public/tags/websocket/index.xml new file mode 100644 index 0000000..e602e83 --- /dev/null +++ b/public/tags/websocket/index.xml @@ -0,0 +1,19 @@ + + + + Websocket on docs.a2v.space + /tags/websocket/ + Recent content in Websocket on docs.a2v.space + Hugo + ru-ru + Tue, 07 Apr 2026 00:00:00 +0000 + + + Leadera — WebSocket для диаграмм Ганта + /leadera/leadera-websocket-gantt/ + Tue, 07 Apr 2026 00:00:00 +0000 + /leadera/leadera-websocket-gantt/ + <h1 id="websocket-для-диаграмм-ганта--realtime-совместное-редактирование">WebSocket для диаграмм Ганта — realtime-совместное редактирование</h1> <p><strong>Дата:</strong> 07.04.2026 <strong>Статус:</strong> Планирование <strong>Проект:</strong> Leadera (dev — sand.a2v.space)</p> <hr> <h2 id="1-постановка-задачи">1. Постановка задачи</h2> <p>Диаграммы Ганта должны обновляться в реальном времени при совместной работе: если два или более пользователей редактируют один Гант, изменения каждого мгновенно отображаются у остальных — без перезагрузки страницы и ручного обновления.</p> <hr> <h2 id="2-архитектура">2. Архитектура</h2> <pre tabindex="0"><code>Angular frontend ←── WebSocket ──→ Go backend (Hub) ──→ PostgreSQL │ │ │ HTTP (мутации) │ Broadcast └──→ REST API ──→ Service ──→ DB ──→ Hub ──→ все клиенты комнаты </code></pre><p><strong>Ключевой принцип:</strong> мутации данных идут через HTTP (REST), WebSocket используется <strong>только для доставки уведомлений</strong>. Сервер — источник истины, клиент не может обойти проверки через WS.</p> + + + diff --git a/static/css/custom.css b/static/css/custom.css index 6f13908..632b8b7 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -270,6 +270,27 @@ body { font-family: monospace; } +/* Source path */ +.source-path { + background-color: var(--warm-bg); + border: 1px solid #e0d8cc; + border-radius: 4px; + padding: 0.4rem 0.75rem; + display: inline-block; +} +.source-path code { + font-size: 0.82em; + color: var(--warm-gray); +} +.source-path .btn-outline-secondary { + font-size: 0.75rem; + border-color: #d0c8bc; +} +.source-path .btn-outline-secondary:hover { + background-color: var(--warm-primary); + border-color: var(--warm-primary); +} + /* Article styles */ .article-content { background-color: var(--warm-white); diff --git a/themes/a2v/layouts/_default/list.html b/themes/a2v/layouts/_default/list.html index 3fc892d..22cc39a 100644 --- a/themes/a2v/layouts/_default/list.html +++ b/themes/a2v/layouts/_default/list.html @@ -4,7 +4,7 @@ {{ .Content }}
        - {{ range .Pages }} + {{ range .Pages.ByDate.Reverse }}
      • {{ .Title }}
      • {{ end }}
      diff --git a/themes/a2v/layouts/_default/single.html b/themes/a2v/layouts/_default/single.html index c52d866..06c5add 100644 --- a/themes/a2v/layouts/_default/single.html +++ b/themes/a2v/layouts/_default/single.html @@ -1,6 +1,7 @@ {{ define "main" }}

      {{ .Title }}

      + {{ partial "source-path.html" . }} {{ .Content }}
      {{ end }} diff --git a/themes/a2v/layouts/creds/list.html b/themes/a2v/layouts/creds/list.html index 66019fa..8b9612f 100644 --- a/themes/a2v/layouts/creds/list.html +++ b/themes/a2v/layouts/creds/list.html @@ -4,7 +4,7 @@

      Данные зашифрованы. Нажмите "Расшифровать" и введите мастер-пароль.

      - {{ range where .Site.RegularPages "Section" "creds" }} + {{ range (where .Site.RegularPages "Section" "creds").ByDate.Reverse }}
      diff --git a/themes/a2v/layouts/creds/single.html b/themes/a2v/layouts/creds/single.html index a6a7315..3557a39 100644 --- a/themes/a2v/layouts/creds/single.html +++ b/themes/a2v/layouts/creds/single.html @@ -1,6 +1,7 @@ {{ define "main" }}

      {{ .Title }}

      + {{ partial "source-path.html" . }} {{ if .Params.description }}

      {{ .Params.description }}

      diff --git a/themes/a2v/layouts/diary/single.html b/themes/a2v/layouts/diary/single.html index 5c7499e..1c8a245 100644 --- a/themes/a2v/layouts/diary/single.html +++ b/themes/a2v/layouts/diary/single.html @@ -2,6 +2,7 @@

      {{ .Title }}

      {{ .Date.Format "02.01.2006" }}

      + {{ partial "source-path.html" . }} {{ .Content }} diff --git a/themes/a2v/layouts/guides/list.html b/themes/a2v/layouts/guides/list.html index 9aa3e8e..80bcd5d 100644 --- a/themes/a2v/layouts/guides/list.html +++ b/themes/a2v/layouts/guides/list.html @@ -3,7 +3,7 @@

      Инструкции

      - {{ range where .Site.RegularPages "Section" "guides" }} + {{ range (where .Site.RegularPages "Section" "guides").ByDate.Reverse }}
      diff --git a/themes/a2v/layouts/guides/single.html b/themes/a2v/layouts/guides/single.html index 12a424f..d60921f 100644 --- a/themes/a2v/layouts/guides/single.html +++ b/themes/a2v/layouts/guides/single.html @@ -1,6 +1,7 @@ {{ define "main" }}

      {{ .Title }}

      + {{ partial "source-path.html" . }} {{ if .Params.tags }}
      diff --git a/themes/a2v/layouts/kb/list.html b/themes/a2v/layouts/kb/list.html index 345947c..6e60c28 100644 --- a/themes/a2v/layouts/kb/list.html +++ b/themes/a2v/layouts/kb/list.html @@ -3,7 +3,7 @@

      База знаний

      - {{ range where .Site.RegularPages "Section" "kb" }} + {{ range (where .Site.RegularPages "Section" "kb").ByDate.Reverse }}
      diff --git a/themes/a2v/layouts/kb/single.html b/themes/a2v/layouts/kb/single.html index 12a424f..d60921f 100644 --- a/themes/a2v/layouts/kb/single.html +++ b/themes/a2v/layouts/kb/single.html @@ -1,6 +1,7 @@ {{ define "main" }}

      {{ .Title }}

      + {{ partial "source-path.html" . }} {{ if .Params.tags }}
      diff --git a/themes/a2v/layouts/leadera/list.html b/themes/a2v/layouts/leadera/list.html new file mode 100644 index 0000000..9a4bac2 --- /dev/null +++ b/themes/a2v/layouts/leadera/list.html @@ -0,0 +1,27 @@ +{{ define "main" }} +
      +

      {{ .Title }}

      + + {{ .Content }} + +
      + {{ range .Pages.ByDate.Reverse }} +
      +
      +
      +

      {{ .Title }}

      +

      {{ .Summary }}

      + {{ if .Params.tags }} +
      + {{ range .Params.tags }} + {{ . }} + {{ end }} +
      + {{ end }} +
      +
      +
      + {{ end }} +
      +
      +{{ end }} diff --git a/themes/a2v/layouts/leadera/single.html b/themes/a2v/layouts/leadera/single.html new file mode 100644 index 0000000..d60921f --- /dev/null +++ b/themes/a2v/layouts/leadera/single.html @@ -0,0 +1,16 @@ +{{ define "main" }} +
      +

      {{ .Title }}

      + {{ partial "source-path.html" . }} + + {{ if .Params.tags }} +
      + {{ range .Params.tags }} + {{ . }} + {{ end }} +
      + {{ end }} + + {{ .Content }} +
      +{{ end }} diff --git a/themes/a2v/layouts/partials/source-path.html b/themes/a2v/layouts/partials/source-path.html new file mode 100644 index 0000000..128e9fc --- /dev/null +++ b/themes/a2v/layouts/partials/source-path.html @@ -0,0 +1,8 @@ +
      + + 📁 ~/Projects/docs-a2v/content/{{ .File.Path }} + + +
      diff --git a/themes/a2v/layouts/plans/single.html b/themes/a2v/layouts/plans/single.html index a1df8e9..4f3409a 100644 --- a/themes/a2v/layouts/plans/single.html +++ b/themes/a2v/layouts/plans/single.html @@ -1,5 +1,8 @@ {{ define "main" }}
      +

      {{ .Title }}

      + {{ partial "source-path.html" . }} +

      {{ .Title }}