--- /dev/null
+---
+name: changelog
+description: Генерирует summary изменений из git diff/log. Используй когда нужно создать CHANGELOG запись, получить обзор изменений, подготовить release notes или описание для PR.
+---
+
+# Changelog Generator
+
+Генерирует детальное структурированное summary изменений с цветовым выделением.
+
+## ⚡ ПЕРВЫЙ ШАГ: Запрос разрешений
+
+**ОБЯЗАТЕЛЬНО** перед началом работы запроси у пользователя разрешение на выполнение команд.
+
+### Шаблон запроса разрешений
+
+Используй `AskUserQuestion` tool с таким форматом:
+
+```text
+Для генерации changelog мне потребуется выполнить следующие команды:
+
+📋 **Git команды (только чтение):**
+• `git status` — статус рабочей директории
+• `git diff --stat HEAD` — статистика изменений
+• `git diff --name-status HEAD` — список изменённых файлов
+• `git diff HEAD` — содержимое изменений
+• `git log --oneline -10` — последние коммиты
+• `git branch --show-current` — текущая ветка
+
+📁 **Анализ файлов:**
+• Чтение изменённых файлов для понимания контекста
+
+Разрешить выполнение?
+```
+
+### Варианты ответа пользователя
+
+Предложи варианты через `AskUserQuestion`:
+
+| Вариант | Описание |
+| ------- | -------- |
+| Разрешить все | Выполнить полный анализ |
+| Только статус | Минимальный отчёт (git status + diff --stat) |
+| Показать команды | Вывести команды без выполнения |
+
+### Пример использования AskUserQuestion
+
+```json
+{
+ "questions": [{
+ "question": "Какой уровень анализа выполнить для changelog?",
+ "header": "Changelog",
+ "options": [
+ {
+ "label": "Полный анализ (Рекомендуется)",
+ "description": "git status, diff, log + анализ содержимого файлов"
+ },
+ {
+ "label": "Быстрый отчёт",
+ "description": "Только git status и diff --stat"
+ },
+ {
+ "label": "Показать план",
+ "description": "Показать какие команды будут выполнены"
+ }
+ ],
+ "multiSelect": false
+ }]
+}
+```
+
+### После получения разрешения
+
+1. **Полный анализ** → выполнить все команды из секции "Команды для сбора данных"
+2. **Быстрый отчёт** → только `git status -s` и `git diff --stat HEAD`
+3. **Показать план** → вывести список команд и спросить повторно
+
+---
+
+## Кликабельные ссылки на файлы
+
+**ОБЯЗАТЕЛЬНО** все пути к файлам оформляй как markdown-ссылки для IDE:
+
+### Формат ссылок
+
+```markdown
+# Файл (кликабельный)
+[filename.php](path/to/filename.php)
+
+# Файл с номером строки
+[filename.php:42](path/to/filename.php#L42)
+
+# Диапазон строк
+[filename.php:10-25](path/to/filename.php#L10-L25)
+
+# Папка
+[controllers/](controllers/)
+```
+
+### Примеры в changelog
+
+```text
+🟡 ИЗМЕНЕНО
+───────────────────────────────────────────────────────────────
+~ [OrderController.php](erp24/controllers/OrderController.php) [+45 -12]
+ └─ Новые методы:
+ • [actionExport():52](erp24/controllers/OrderController.php#L52)
+ • [actionBulkUpdate():78](erp24/controllers/OrderController.php#L78)
+```
+
+### Правила форматирования ссылок
+
+| Элемент | Формат | Пример |
+| ------- | ------ | ------ |
+| Изменённый файл | `[name](path)` | `[User.php](models/User.php)` |
+| Новый метод | `[method:line](path#L)` | `[save():45](models/User.php#L45)` |
+| Удалённый файл | `~~[name](path)~~` | `~~[Old.php](old/Old.php)~~` |
+| Папка | `[name/](path/)` | `[services/](app/services/)` |
+
+---
+
+## Цветовая схема вывода
+
+При выводе summary ОБЯЗАТЕЛЬНО используй цветовое выделение:
+
+| Тип | Цвет/Формат | Пример |
+| --- | ----------- | ------ |
+| Добавлено | 🟢 **зелёный** | `+ новый файл` |
+| Изменено | 🟡 **жёлтый** | `~ модификация` |
+| Удалено | 🔴 **красный** | `- удалённый файл` |
+| Переименовано | 🔵 **синий** | `→ старое → новое` |
+| Критично | ⚠️ **предупреждение** | Миграции, безопасность |
+| Статистика | 📊 **инфо** | Строки, покрытие |
+
+## Формат детального вывода
+
+### Шаблон summary
+
+```text
+═══════════════════════════════════════════════════════════════
+ 📋 SUMMARY ИЗМЕНЕНИЙ
+═══════════════════════════════════════════════════════════════
+
+📅 Дата: YYYY-MM-DD HH:MM
+🌿 Ветка: feature/xxx → develop
+👤 Автор: name
+
+───────────────────────────────────────────────────────────────
+🟢 ДОБАВЛЕНО (N файлов)
+───────────────────────────────────────────────────────────────
++ [new-file.php](path/to/new-file.php) [+150 строк]
+ └─ Описание: Новый сервис для обработки заказов
+ └─ Классы: OrderProcessingService
+ └─ Методы: [process():15](path/to/new-file.php#L15), validate(), notify()
+
++ [another-file.ts](path/to/another-file.ts) [+80 строк]
+ └─ Описание: React компонент формы
+ └─ Компоненты: OrderForm, OrderFormProps
+
+───────────────────────────────────────────────────────────────
+🟡 ИЗМЕНЕНО (N файлов)
+───────────────────────────────────────────────────────────────
+~ [modified-file.php](path/to/modified-file.php) [+50 -20 строк]
+ └─ Что изменено:
+ • Добавлен [calculateDiscount():42](path/to/modified-file.php#L42)
+ • Рефакторинг [processOrder():78](path/to/modified-file.php#L78)
+ • Обновлены типы параметров
+ └─ Затронутые методы: calculate(), process(), validate()
+
+~ [config.php](path/to/config.php) [+5 -2 строк]
+ └─ Что изменено:
+ • Добавлен новый параметр 'cache_ttl'
+ • Изменено значение 'timeout': 30 → 60
+
+───────────────────────────────────────────────────────────────
+🔴 УДАЛЕНО (N файлов)
+───────────────────────────────────────────────────────────────
+- ~~[deprecated-file.php](path/to/deprecated-file.php)~~ [-200 строк]
+ └─ Причина: Заменён на NewService
+ └─ Зависимости: Проверить [Controller.php](path/to/Controller.php)
+
+───────────────────────────────────────────────────────────────
+🔵 ПЕРЕИМЕНОВАНО (N файлов)
+───────────────────────────────────────────────────────────────
+→ [old-name.php](old-name.php) → [new-name.php](new-name.php)
+ └─ Причина: Соответствие PSR-4
+
+───────────────────────────────────────────────────────────────
+⚠️ ТРЕБУЕТ ВНИМАНИЯ
+───────────────────────────────────────────────────────────────
+• 🗄️ Миграции: m240115_120000_add_orders_table.php
+ └─ Действие: php yii migrate
+ └─ Откат: php yii migrate/down 1
+
+• 🔐 Безопасность: изменения в auth/
+ └─ Проверить: права доступа, токены
+
+• ⚙️ Конфигурация: .env.example обновлён
+ └─ Добавить: NEW_API_KEY в .env
+
+───────────────────────────────────────────────────────────────
+📊 СТАТИСТИКА
+───────────────────────────────────────────────────────────────
+Файлов изменено: 12
+Строк добавлено: +1,250 🟢
+Строк удалено: -340 🔴
+Чистый прирост: +910
+
+По категориям:
+ Controllers: 3 файла (+200 -50)
+ Models: 4 файла (+400 -100)
+ Services: 2 файла (+300 -90)
+ Views: 2 файла (+250 -80)
+ Tests: 1 файл (+100 -20)
+
+═══════════════════════════════════════════════════════════════
+```
+
+## Команды для сбора данных
+
+### 1. Детальная информация об изменениях
+
+```bash
+# Статистика по файлам с цветом
+git diff --stat --color HEAD
+
+# Подробный diff для анализа ЧТО изменилось
+git diff HEAD -- "*.php" | head -100
+
+# Изменённые функции/методы (для PHP)
+git diff HEAD --function-context -- "*.php"
+
+# Список всех изменений с типом
+git diff --name-status HEAD
+```
+
+### 2. Анализ содержимого изменений
+
+```bash
+# Найти добавленные/удалённые методы
+git diff HEAD | grep -E "^\+.*function |^\-.*function "
+
+# Найти изменённые классы
+git diff HEAD | grep -E "^\+.*class |^\-.*class "
+
+# Найти новые use/import
+git diff HEAD | grep -E "^\+.*use |^\+.*import "
+```
+
+### 3. Для коммитов
+
+```bash
+# Детальный лог с изменениями
+git log --stat --oneline -5
+
+# Показать что изменено в каждом коммите
+git log -p --oneline -3
+
+# Группировка по автору
+git shortlog -sn --since="1 week ago"
+```
+
+## Workflow детального анализа
+
+### Шаг 1: Собрать сырые данные
+
+```bash
+# 1. Список файлов
+git diff --name-status HEAD > /tmp/files.txt
+
+# 2. Статистика строк
+git diff --numstat HEAD > /tmp/stats.txt
+
+# 3. Содержимое изменений для анализа
+git diff HEAD > /tmp/diff.txt
+```
+
+### Шаг 2: Проанализировать каждый файл
+
+Для каждого изменённого файла определить:
+
+1. **Тип файла** → категория (Controller, Model, Service, etc.)
+2. **Что добавлено** → новые методы, классы, свойства
+3. **Что изменено** → модифицированные методы, параметры
+4. **Что удалено** → удалённые методы, deprecated код
+5. **Зависимости** → что может сломаться
+
+### Шаг 3: Классифицировать по важности
+
+| Приоритет | Категория | Описание |
+| --------- | --------- | -------- |
+| 🔴 Critical | migrations, security, .env | Требует действий |
+| 🟠 High | API changes, breaking changes | Влияет на других |
+| 🟡 Medium | business logic, services | Основная работа |
+| 🟢 Low | docs, tests, refactoring | Улучшения |
+
+### Шаг 4: Сформировать вывод
+
+Использовать шаблон выше с заполненными данными.
+
+## Примеры детального вывода
+
+### Пример: Изменённый контроллер
+
+```text
+🟡 ИЗМЕНЕНО
+───────────────────────────────────────────────────────────────
+~ [OrderController.php](erp24/controllers/OrderController.php) [+45 -12 строк]
+ │
+ ├─ Новые методы:
+ │ • [actionExport():52](erp24/controllers/OrderController.php#L52) — экспорт заказов в Excel
+ │ • [actionBulkUpdate():89](erp24/controllers/OrderController.php#L89) — массовое обновление
+ │
+ ├─ Изменённые методы:
+ │ • [actionIndex():15](erp24/controllers/OrderController.php#L15) — добавлена пагинация
+ │ • [actionView():34](erp24/controllers/OrderController.php#L34) — добавлено кеширование
+ │
+ ├─ Новые зависимости:
+ │ • use [ExportService](app/services/ExportService.php)
+ │ • use [CacheHelper](app/helpers/CacheHelper.php)
+ │
+ └─ Влияние:
+ • API: новые эндпоинты /order/export, /order/bulk-update
+ • Права: требуется permission 'order.export'
+```
+
+### Пример: Новая миграция
+
+```text
+⚠️ ТРЕБУЕТ ВНИМАНИЯ
+───────────────────────────────────────────────────────────────
+🗄️ Миграция: [m240128_100000_add_order_status_history.php](migrations/m240128_100000_add_order_status_history.php)
+
+ Создаёт таблицу: order_status_history
+ ┌─────────────────┬──────────┬─────────────────────┐
+ │ Поле │ Тип │ Описание │
+ ├─────────────────┼──────────┼─────────────────────┤
+ │ id │ int PK │ ID записи │
+ │ order_id │ int FK │ Связь с orders │
+ │ old_status │ int │ Предыдущий статус │
+ │ new_status │ int │ Новый статус │
+ │ created_at │ datetime │ Время изменения │
+ │ created_by │ int FK │ Кто изменил │
+ └─────────────────┴──────────┴─────────────────────┘
+
+ Действия:
+ ✅ Применить: php yii migrate
+ ↩️ Откатить: php yii migrate/down 1
+
+ ⚠️ Внимание: требуется ~30 сек на больших данных
+```
+
+## Интеграция с другими skills
+
+### После /changelog → /git-commit
+
+Summary используется для формирования commit message:
+
+```text
+feat(orders): add export and bulk update functionality
+
+- Add actionExport() for Excel export
+- Add actionBulkUpdate() for mass status changes
+- Improve actionIndex() with pagination and filters
+- Add order_status_history migration
+
+Affects: OrderController, ExportService
+Migration: m240128_100000_add_order_status_history
+```
+
+## Важные правила
+
+- **ВСЕГДА** читать реальный diff для понимания изменений
+- **ДЕТАЛИЗИРОВАТЬ** что именно изменилось (методы, параметры)
+- **ВЫДЕЛЯТЬ** критичные изменения (миграции, безопасность, API)
+- **ГРУППИРОВАТЬ** по категориям с emoji-маркерами
+- **УКАЗЫВАТЬ** влияние изменений на другие части системы
+- **ФОРМАТИРОВАТЬ** с использованием рамок и отступов для читаемости
--- /dev/null
+# Spec Debate
+
+Interactive adversarial specification refinement using multiple LLM models.
+
+## Quick Start
+
+```bash
+# Interactive mode
+/spec-debate
+
+# Review existing spec
+/spec-debate ./docs/my-spec.md
+
+# Generate tech spec from PRD
+/spec-debate --from=./prd.md --to=tech-spec
+```
+
+## Features
+
+### Custom Focus
+
+Focus critique on specific aspects:
+
+```bash
+# Predefined focuses
+/spec-debate ./spec.md --focus=security
+/spec-debate ./spec.md --focus=performance
+
+# Custom focus (any text)
+/spec-debate ./spec.md --focus="API backward compatibility and versioning"
+/spec-debate ./prd.md --focus="retail analytics, POS integration, real-time reporting"
+```
+
+### Document Generation
+
+Generate new specifications based on existing documents:
+
+```bash
+# Tech spec from PRD
+/spec-debate --from=./prd-v3.md --to=tech-spec
+
+# API spec from tech spec
+/spec-debate --from=./tech-spec.md --to=api-spec
+
+# DB schema from tech spec
+/spec-debate --from=./tech-spec.md --to=db-schema
+
+# Deployment spec from tech spec
+/spec-debate --from=./tech-spec.md --to=deployment-spec
+```
+
+### Combined Mode
+
+Generate with specific focus:
+
+```bash
+/spec-debate --from=./prd.md --to=tech-spec --focus="database schema design"
+/spec-debate --from=./prd.md --to=tech-spec --focus="microservices boundaries"
+```
+
+### Custom Persona
+
+Models can critique from a specific professional perspective:
+
+```bash
+# Predefined personas
+/spec-debate ./spec.md --persona=security-engineer
+/spec-debate ./spec.md --persona=oncall-engineer
+
+# Custom persona
+/spec-debate ./spec.md --persona="retail domain expert with 10 years experience"
+/spec-debate ./spec.md --persona="ML engineer focused on data pipelines"
+```
+
+## Target Document Types
+
+| Type | Description | Typical Source |
+|------|-------------|----------------|
+| `prd` | Product Requirements Document | Concept/description |
+| `tech-spec` | Technical Specification | PRD |
+| `api-spec` | API Specification (REST/GraphQL) | PRD or Tech Spec |
+| `db-schema` | Database Schema Documentation | Tech Spec |
+| `deployment-spec` | Infrastructure/Deployment Spec | Tech Spec |
+
+## Predefined Focus Modes
+
+| Mode | Description |
+|------|-------------|
+| `security` | Auth, encryption, vulnerabilities |
+| `scalability` | Scaling, sharding, caching |
+| `performance` | Latency, throughput, query optimization |
+| `ux` | User journeys, error handling, accessibility |
+| `reliability` | Failure modes, circuit breakers, DR |
+| `cost` | Infrastructure expenses, resource efficiency |
+
+## Predefined Personas
+
+| Persona | Perspective |
+|---------|-------------|
+| `security-engineer` | Adversarial thinking, paranoid about edge cases |
+| `oncall-engineer` | Observability, error messages, 3am debugging |
+| `junior-developer` | Flags ambiguity and tribal knowledge |
+| `qa-engineer` | Test scenarios, acceptance criteria |
+| `site-reliability` | Deployment, monitoring, incident response |
+| `product-manager` | User value, success metrics |
+| `data-engineer` | Data models, ETL implications |
+| `mobile-developer` | API design from mobile perspective |
+| `accessibility-specialist` | WCAG compliance |
+| `legal-compliance` | GDPR, CCPA, regulatory |
+
+## All Arguments
+
+| Argument | Description |
+|----------|-------------|
+| `<path>` | Path to spec file to review |
+| `--focus="..."` | Custom critique focus |
+| `--from=<path>` | Source document for generation |
+| `--to=<type>` | Target document type |
+| `--persona="..."` | Custom persona |
+| `--models=<list>` | Comma-separated model list |
+
+## Examples
+
+```bash
+# Simple review
+/spec-debate ./docs/api-spec.md
+
+# Security-focused review
+/spec-debate ./api-spec.md --focus="authentication, authorization, input validation"
+
+# Generate tech spec from PRD with domain focus
+/spec-debate --from=./prd-transcribe-v3.md --to=tech-spec --focus="audio processing pipeline"
+
+# Review with custom persona
+/spec-debate ./tech-spec.md --persona="database administrator with PostgreSQL expertise"
+
+# Full example
+/spec-debate --from=./prd.md --to=tech-spec --focus="scalability" --persona=site-reliability
+```
+
+## How It Works
+
+1. **Parse arguments** - Extract flags and options
+2. **Find/generate spec** - Load existing or generate from source
+3. **Select models** - Uses OpenRouter models by default (5 models)
+4. **Run debate loop** - Each model critiques, Claude synthesizes
+5. **Iterate** - Repeat until consensus reached
+6. **Output** - Final spec written to `spec-output.md`
--- /dev/null
+---
+name: spec-debate
+description: Interactive wrapper for adversarial-spec with UI for selecting spec files, focus modes, personas, and models. Use when you want to run adversarial spec debate with custom options.
+allowed-tools: Bash, Read, Write, Glob, AskUserQuestion
+---
+
+# Spec Debate - Interactive Adversarial Specification
+
+Launch adversarial spec debates with interactive selection of all options.
+
+## Execution Flow
+
+### Step 0: Import Windows Environment Variables (WSL)
+
+Before checking providers, import API keys from Windows environment:
+
+```bash
+# Get OPENROUTER_API_KEY from Windows and export to current shell
+export OPENROUTER_API_KEY="$(cmd.exe /c echo %OPENROUTER_API_KEY% 2>/dev/null | tr -d '\r')"
+```
+
+### Step 1: Check Available Providers
+
+First, check which API keys are configured:
+
+```bash
+python3 ~/.claude/plugins/marketplaces/adversarial-spec/skills/adversarial-spec/scripts/debate.py providers
+```
+
+Parse output to determine available models.
+
+### Step 2: Find Spec Files
+
+Search for potential spec files in the project:
+
+```bash
+find . -maxdepth 3 \( -name "*.md" -o -name "*spec*" -o -name "*prd*" -o -name "*requirements*" \) -type f 2>/dev/null | head -20
+```
+
+### Step 3: Interactive Selection
+
+Use AskUserQuestion to gather all options in sequence:
+
+#### 3.1 Document Source
+
+**If `--from` argument provided, skip to 3.1.1 (Generation mode).**
+
+Ask user:
+```
+question: "How do you want to provide the specification?"
+header: "Source"
+options:
+ - label: "Select existing file"
+ description: "Choose from found spec files in the project"
+ - label: "Generate from document"
+ description: "Create new spec based on existing PRD/spec (e.g., tech-spec from PRD)"
+ - label: "Enter file path manually"
+ description: "Specify custom path to spec file"
+ - label: "Describe concept"
+ description: "I'll generate initial spec from your description"
+```
+
+#### 3.1.1 Generation Mode (if "Generate from document" selected or --from provided)
+
+First, ask for source document (if not provided via --from):
+```
+question: "Which document should be the source?"
+header: "Source Doc"
+options: [dynamically built from found spec/PRD files]
+```
+
+Then ask for target type (if not provided via --to):
+```
+question: "What type of document do you want to generate?"
+header: "Target Type"
+options:
+ - label: "Tech Spec (Recommended)"
+ description: "Technical specification for developers - architecture, components, APIs"
+ - label: "API Spec"
+ description: "REST/GraphQL API specification with endpoints, schemas, examples"
+ - label: "DB Schema"
+ description: "Database schema with tables, relations, indexes, migrations"
+ - label: "Deployment Spec"
+ description: "Infrastructure, containers, CI/CD, monitoring setup"
+```
+
+#### 3.2 Document Type
+
+Ask user:
+```
+question: "What type of document are you creating?"
+header: "Doc Type"
+options:
+ - label: "PRD (Recommended)"
+ description: "Product Requirements Document - for stakeholders, PMs, designers"
+ - label: "Tech Spec"
+ description: "Technical Specification - for developers and architects"
+```
+
+#### 3.3 Critique Focus Mode (Optional)
+
+**If `--focus` argument provided, skip this step and use the provided focus.**
+
+Ask user:
+```
+question: "Do you want to focus the critique on a specific area?"
+header: "Focus"
+options:
+ - label: "No specific focus (Recommended)"
+ description: "General comprehensive critique"
+ - label: "Custom focus"
+ description: "Enter your own focus area (e.g., 'API versioning', 'data privacy')"
+ - label: "Security"
+ description: "Auth, validation, encryption, vulnerabilities"
+ - label: "Scalability"
+ description: "Horizontal scaling, sharding, caching"
+```
+
+If "Custom focus" selected, ask:
+```
+question: "Describe your focus area (what aspects should critiques concentrate on?)"
+header: "Custom Focus"
+options: (use Other input)
+```
+
+If user wants different predefined focus, offer second question with remaining options:
+- `performance` - Latency, throughput, query optimization
+- `ux` - User journeys, error handling, accessibility
+- `reliability` - Failure modes, circuit breakers, disaster recovery
+- `cost` - Infrastructure expenses, resource efficiency
+
+#### 3.4 Model Persona (Optional)
+
+**If `--persona` argument provided, skip this step and use the provided persona.**
+
+Ask user:
+```
+question: "Should models critique from a specific professional perspective?"
+header: "Persona"
+options:
+ - label: "No persona (Recommended)"
+ description: "General critique without role-playing"
+ - label: "Custom persona"
+ description: "Enter your own persona (e.g., 'retail domain expert', 'ML engineer')"
+ - label: "Security Engineer"
+ description: "Adversarial thinking, paranoid about edge cases"
+ - label: "On-call Engineer"
+ description: "Observability, error messages, 3am debugging"
+```
+
+If "Custom persona" selected, ask:
+```
+question: "Describe the persona (what professional perspective should models adopt?)"
+header: "Custom Persona"
+options: (use Other input)
+```
+
+If user wants different predefined persona, offer second question with:
+- `junior-developer` - Flags ambiguity and tribal knowledge
+- `qa-engineer` - Test scenarios, acceptance criteria
+- `site-reliability` - Deployment, monitoring, incident response
+- `product-manager` - User value, success metrics
+- `data-engineer` - Data models, ETL implications
+- `mobile-developer` - API design from mobile perspective
+- `accessibility-specialist` - WCAG, screen reader support
+- `legal-compliance` - GDPR, CCPA, regulatory
+
+#### 3.5 Model Selection
+
+**If OPENROUTER_API_KEY is set, use these models by default (skip asking user):**
+```
+openrouter/deepseek/deepseek-v3.2,openrouter/google/gemini-3-pro-preview,openrouter/x-ai/grok-4.1-fast,openrouter/z-ai/glm-4.7,openrouter/openai/gpt-5.2
+```
+
+Otherwise, based on available providers from Step 1, build options dynamically.
+
+Use multiSelect to allow choosing multiple models:
+```
+question: "Which models should participate in the debate?"
+header: "Models"
+multiSelect: true
+options: [dynamically built based on available API keys]
+```
+
+**Model mapping by provider:**
+- OpenAI: `gpt-4o`, `o1`
+- Anthropic: `claude-sonnet-4-20250514`, `claude-opus-4-20250514`
+- Google: `gemini/gemini-2.0-flash`
+- xAI: `xai/grok-3`
+- Mistral: `mistral/mistral-large`
+- Groq: `groq/llama-3.3-70b-versatile`
+- Deepseek: `deepseek/deepseek-chat`
+- OpenRouter (default): `openrouter/deepseek/deepseek-v3.2`, `openrouter/google/gemini-3-pro-preview`, `openrouter/x-ai/grok-4.1-fast`, `openrouter/z-ai/glm-4.7`, `openrouter/openai/gpt-5.2`
+- Codex CLI: `codex/gpt-5.2-codex`
+- Gemini CLI: `gemini-cli/gemini-3-pro-preview`
+
+#### 3.6 Additional Options
+
+Ask user:
+```
+question: "Any additional options?"
+header: "Options"
+multiSelect: true
+options:
+ - label: "Preserve Intent"
+ description: "Require justification for any content removal"
+ - label: "Telegram notifications"
+ description: "Get real-time updates via Telegram bot"
+ - label: "Save session"
+ description: "Enable session persistence for resume"
+```
+
+### Step 4: Generate Initial Spec (if Generation Mode)
+
+**If `--from` was specified or "Generate from document" was selected:**
+
+1. Read the source document
+2. Generate initial spec based on target type:
+
+```
+For tech-spec from PRD:
+- Extract functional requirements
+- Design system architecture
+- Define components and their responsibilities
+- Specify APIs and data flows
+- Document technical constraints and decisions
+
+For api-spec:
+- Extract endpoints from requirements
+- Define request/response schemas
+- Document authentication and authorization
+- Specify error codes and handling
+
+For db-schema:
+- Extract entities from requirements
+- Design normalized schema
+- Define indexes and constraints
+- Document migrations strategy
+
+For deployment-spec:
+- Define container architecture
+- Specify resource requirements
+- Document CI/CD pipeline
+- Define monitoring and alerting
+```
+
+3. Write generated spec to memory (for debate)
+
+### Step 5: Build and Execute Command
+
+Construct the debate command from selections:
+
+```bash
+python3 ~/.claude/plugins/marketplaces/adversarial-spec/skills/adversarial-spec/scripts/debate.py critique \
+ --models MODEL_LIST \
+ --doc-type TYPE \
+ [--focus FOCUS] \
+ [--persona PERSONA] \
+ [--preserve-intent] \
+ [--telegram] \
+ [--session SESSION_NAME] \
+ <<'SPEC_EOF'
+<spec content here - either from file or generated>
+SPEC_EOF
+```
+
+**Note**: For custom focus/persona, pass the exact string provided by user:
+```bash
+--focus "API backward compatibility and versioning"
+--persona "retail domain expert with 10 years experience"
+```
+
+### Step 6: Run Debate Loop
+
+Follow the adversarial-spec process:
+1. Send spec to selected models
+2. Collect critiques
+3. Provide your own critique as Claude
+4. Synthesize and revise
+5. Repeat until all models agree
+
+### Step 7: Output Results
+
+When consensus reached:
+1. Display final spec
+2. Write to `spec-output.md`
+3. Show summary with rounds count, contributions
+
+## Quick Reference
+
+**Focus Modes:**
+| Mode | Description |
+|------|-------------|
+| security | Auth, encryption, vulnerabilities |
+| scalability | Scaling, sharding, caching |
+| performance | Latency, throughput |
+| ux | User journeys, accessibility |
+| reliability | Failure modes, DR |
+| cost | Infrastructure costs |
+
+**Personas:**
+| Persona | Perspective |
+|---------|-------------|
+| security-engineer | Adversarial thinking |
+| oncall-engineer | 3am debugging |
+| junior-developer | Ambiguity detection |
+| qa-engineer | Test scenarios |
+| site-reliability | Deployment, monitoring |
+| product-manager | User value, metrics |
+| data-engineer | Data models, ETL |
+| mobile-developer | Mobile API design |
+| accessibility-specialist | WCAG compliance |
+| legal-compliance | GDPR, CCPA |
+
+## Arguments
+
+`$ARGUMENTS` can contain:
+- Path to spec file: `/spec-debate ./docs/my-spec.md`
+- Description: `/spec-debate "Build a rate limiter service"`
+- Custom focus: `/spec-debate --focus="API backward compatibility and versioning"`
+- Generate from source: `/spec-debate --from=./prd.md --to=tech-spec`
+- Combined: `/spec-debate --from=./prd.md --to=tech-spec --focus="database schema design"`
+- Empty: Interactive mode with all questions
+
+### Argument Parsing
+
+Parse `$ARGUMENTS` for these flags:
+- `--focus="<custom focus>"` - Custom critique focus (overrides interactive selection)
+- `--from=<path>` - Source document to base new spec on
+- `--to=<type>` - Target document type: `prd`, `tech-spec`, `api-spec`, `db-schema`, `deployment-spec`
+- `--persona="<custom persona>"` - Custom persona (overrides interactive selection)
+- `--models=<model1,model2>` - Comma-separated list of models (overrides default)
+
+### Generation Modes
+
+When `--from` is specified, the workflow changes:
+
+#### Mode: Generate Tech Spec from PRD
+
+```bash
+/spec-debate --from=./prd-v3.md --to=tech-spec
+```
+
+1. Read source document (PRD)
+2. Generate initial tech spec based on PRD requirements
+3. Run adversarial debate on generated spec
+4. Output: refined tech spec that implements PRD
+
+#### Mode: Generate API Spec from PRD/Tech Spec
+
+```bash
+/spec-debate --from=./tech-spec.md --to=api-spec
+```
+
+Generates OpenAPI-style API specification.
+
+#### Mode: Generate DB Schema from Tech Spec
+
+```bash
+/spec-debate --from=./tech-spec.md --to=db-schema
+```
+
+Generates database schema documentation with tables, relations, indexes.
+
+#### Mode: Generate Deployment Spec
+
+```bash
+/spec-debate --from=./tech-spec.md --to=deployment-spec
+```
+
+Generates deployment/infrastructure specification.
+
+### Target Types
+
+| Type | Description | Typical Source |
+|------|-------------|----------------|
+| `prd` | Product Requirements Document | Concept/description |
+| `tech-spec` | Technical Specification | PRD |
+| `api-spec` | API Specification (REST/GraphQL) | PRD or Tech Spec |
+| `db-schema` | Database Schema Documentation | Tech Spec |
+| `deployment-spec` | Infrastructure/Deployment Spec | Tech Spec |
+
+### Custom Focus Examples
+
+```bash
+# Security-focused review
+/spec-debate ./api-spec.md --focus="authentication, authorization, input validation"
+
+# Performance-focused
+/spec-debate ./tech-spec.md --focus="query optimization, caching strategy, N+1 problems"
+
+# Domain-specific
+/spec-debate ./prd.md --focus="retail analytics, POS integration, real-time reporting"
+
+# Generate with focus
+/spec-debate --from=./prd.md --to=tech-spec --focus="microservices boundaries and data ownership"
+```
--- /dev/null
+#!/bin/bash
+# Detect available models based on configured API keys
+# Output: JSON with available models grouped by provider
+
+set -e
+
+# Colors for terminal output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Check if running in JSON mode
+JSON_MODE=false
+if [[ "$1" == "--json" ]]; then
+ JSON_MODE=true
+fi
+
+declare -A PROVIDERS
+declare -a AVAILABLE_MODELS
+
+# Check each provider
+check_provider() {
+ local name="$1"
+ local env_var="$2"
+ local models="$3"
+
+ if [[ -n "${!env_var}" ]]; then
+ PROVIDERS[$name]="configured"
+ IFS=',' read -ra MODEL_ARRAY <<< "$models"
+ for model in "${MODEL_ARRAY[@]}"; do
+ AVAILABLE_MODELS+=("$model")
+ done
+ return 0
+ else
+ PROVIDERS[$name]="not configured"
+ return 1
+ fi
+}
+
+# Check CLI tools
+check_cli() {
+ local name="$1"
+ local cmd="$2"
+ local models="$3"
+
+ if command -v "$cmd" &> /dev/null; then
+ PROVIDERS[$name]="installed"
+ IFS=',' read -ra MODEL_ARRAY <<< "$models"
+ for model in "${MODEL_ARRAY[@]}"; do
+ AVAILABLE_MODELS+=("$model")
+ done
+ return 0
+ else
+ PROVIDERS[$name]="not installed"
+ return 1
+ fi
+}
+
+# Check all providers
+check_provider "OpenAI" "OPENAI_API_KEY" "gpt-4o,o1"
+check_provider "Anthropic" "ANTHROPIC_API_KEY" "claude-sonnet-4-20250514,claude-opus-4-20250514"
+check_provider "Google" "GEMINI_API_KEY" "gemini/gemini-2.0-flash,gemini/gemini-pro"
+check_provider "xAI" "XAI_API_KEY" "xai/grok-3,xai/grok-beta"
+check_provider "Mistral" "MISTRAL_API_KEY" "mistral/mistral-large,mistral/codestral"
+check_provider "Groq" "GROQ_API_KEY" "groq/llama-3.3-70b-versatile"
+check_provider "OpenRouter" "OPENROUTER_API_KEY" "openrouter/openai/gpt-4o,openrouter/anthropic/claude-3.5-sonnet"
+check_provider "Deepseek" "DEEPSEEK_API_KEY" "deepseek/deepseek-chat"
+check_provider "Zhipu" "ZHIPUAI_API_KEY" "zhipu/glm-4,zhipu/glm-4-plus"
+check_cli "Codex CLI" "codex" "codex/gpt-5.2-codex,codex/gpt-5.1-codex-max"
+check_cli "Gemini CLI" "gemini" "gemini-cli/gemini-3-pro-preview,gemini-cli/gemini-3-flash-preview"
+
+if $JSON_MODE; then
+ # JSON output
+ echo "{"
+ echo " \"providers\": {"
+ first=true
+ for provider in "${!PROVIDERS[@]}"; do
+ if $first; then
+ first=false
+ else
+ echo ","
+ fi
+ printf " \"%s\": \"%s\"" "$provider" "${PROVIDERS[$provider]}"
+ done
+ echo ""
+ echo " },"
+ echo " \"available_models\": ["
+ first=true
+ for model in "${AVAILABLE_MODELS[@]}"; do
+ if $first; then
+ first=false
+ else
+ echo ","
+ fi
+ printf " \"%s\"" "$model"
+ done
+ echo ""
+ echo " ]"
+ echo "}"
+else
+ # Human-readable output
+ echo "=== Available Providers ==="
+ echo ""
+ for provider in "${!PROVIDERS[@]}"; do
+ status="${PROVIDERS[$provider]}"
+ if [[ "$status" == "configured" || "$status" == "installed" ]]; then
+ echo -e "${GREEN}✓${NC} $provider: $status"
+ else
+ echo -e "${RED}✗${NC} $provider: $status"
+ fi
+ done
+ echo ""
+ echo "=== Available Models ==="
+ if [[ ${#AVAILABLE_MODELS[@]} -eq 0 ]]; then
+ echo -e "${YELLOW}No models available. Configure at least one API key.${NC}"
+ else
+ for model in "${AVAILABLE_MODELS[@]}"; do
+ echo " - $model"
+ done
+ fi
+fi
--- /dev/null
+#!/bin/bash
+# Wrapper script for running adversarial-spec debates
+# Usage: run-debate.sh [options] < spec.md
+#
+# Options:
+# --models MODEL1,MODEL2 Comma-separated list of models
+# --doc-type prd|tech Document type (default: tech)
+# --focus FOCUS Critique focus area
+# --persona PERSONA Model persona
+# --preserve-intent Require justification for removals
+# --session NAME Session name for persistence
+# --telegram Enable Telegram notifications
+# --round N Round number (default: 1)
+# --press Anti-laziness check
+
+set -e
+
+DEBATE_SCRIPT="$HOME/.claude/plugins/marketplaces/adversarial-spec/scripts/debate.py"
+
+# Check if debate script exists
+if [[ ! -f "$DEBATE_SCRIPT" ]]; then
+ echo "Error: adversarial-spec plugin not found at $DEBATE_SCRIPT"
+ echo "Install with: /plugin install adversarial-spec"
+ exit 1
+fi
+
+# Parse arguments
+MODELS=""
+DOC_TYPE="tech"
+FOCUS=""
+PERSONA=""
+PRESERVE_INTENT=""
+SESSION=""
+TELEGRAM=""
+ROUND="1"
+PRESS=""
+CONTEXT_FILES=()
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --models|-m)
+ MODELS="$2"
+ shift 2
+ ;;
+ --doc-type|-d)
+ DOC_TYPE="$2"
+ shift 2
+ ;;
+ --focus|-f)
+ FOCUS="$2"
+ shift 2
+ ;;
+ --persona)
+ PERSONA="$2"
+ shift 2
+ ;;
+ --preserve-intent)
+ PRESERVE_INTENT="--preserve-intent"
+ shift
+ ;;
+ --session|-s)
+ SESSION="$2"
+ shift 2
+ ;;
+ --telegram|-t)
+ TELEGRAM="--telegram"
+ shift
+ ;;
+ --round|-r)
+ ROUND="$2"
+ shift 2
+ ;;
+ --press|-p)
+ PRESS="--press"
+ shift
+ ;;
+ --context|-c)
+ CONTEXT_FILES+=("--context" "$2")
+ shift 2
+ ;;
+ *)
+ echo "Unknown option: $1"
+ exit 1
+ ;;
+ esac
+done
+
+# Validate required arguments
+if [[ -z "$MODELS" ]]; then
+ echo "Error: --models is required"
+ echo "Usage: run-debate.sh --models gpt-4o,gemini/gemini-2.0-flash < spec.md"
+ exit 1
+fi
+
+# Build command
+CMD="python3 $DEBATE_SCRIPT critique"
+CMD="$CMD --models $MODELS"
+CMD="$CMD --doc-type $DOC_TYPE"
+CMD="$CMD --round $ROUND"
+
+[[ -n "$FOCUS" ]] && CMD="$CMD --focus $FOCUS"
+[[ -n "$PERSONA" ]] && CMD="$CMD --persona \"$PERSONA\""
+[[ -n "$PRESERVE_INTENT" ]] && CMD="$CMD $PRESERVE_INTENT"
+[[ -n "$SESSION" ]] && CMD="$CMD --session $SESSION"
+[[ -n "$TELEGRAM" ]] && CMD="$CMD $TELEGRAM"
+[[ -n "$PRESS" ]] && CMD="$CMD $PRESS"
+
+# Add context files
+for ctx in "${CONTEXT_FILES[@]}"; do
+ CMD="$CMD $ctx"
+done
+
+# Show command being executed
+echo "=== Running Debate ==="
+echo "Models: $MODELS"
+echo "Type: $DOC_TYPE"
+[[ -n "$FOCUS" ]] && echo "Focus: $FOCUS"
+[[ -n "$PERSONA" ]] && echo "Persona: $PERSONA"
+[[ -n "$SESSION" ]] && echo "Session: $SESSION"
+echo "======================"
+echo ""
+
+# Execute
+eval "$CMD"
# Auto Claude data directory
.auto-claude/
+
+# Auto Claude generated files
+.auto-claude-security.json
+.auto-claude-status
+.claude_settings.json
+.worktrees/
+.security-key
+logs/security/
'email_recipients' => array_filter(explode(',', getenv('ORDER_CONTROL_EMAIL_RECIPIENTS') ?: 'vladimir.fomichev@erp-flowers.ru,ekaterina.geldak@bazacvetov24.ru,irina.rogacheva@bazacvetov24.ru,alena.chelyshkina@bazacvetov24.ru')),
'email_subject' => '[Контроль MP] Отчёт о расхождениях статусов заказов',
],
+
+ // Система обязательных напоминаний о сменах
+ // Временные окна для показа напоминаний (в формате HH:MM)
+ 'SHIFT_REMINDER_TIME_WINDOWS' => ['07:50', '08:10', '19:50', '20:10'],
+
+ // Допустимые ключи напоминаний (whitelist для безопасности)
+ 'SHIFT_REMINDER_ALLOWED_KEYS' => ['day_shift', 'night_shift'],
+
+ // ID групп администраторов, которым показываются напоминания
+ // Пустой массив = все пользователи с записями в timetable
+ 'SHIFT_REMINDER_ADMIN_GROUP_IDS' => [],
];
'wiki/<parent_cat_slug>/<article_slug>' => 'wiki/view',
],
],
+ 'shiftReminder' => [
+ 'class' => 'yii_app\services\ShiftReminderService',
+ ],
],
'as beforeRequest' => [
'class' => 'yii\filters\AccessControl',
--- /dev/null
+<?php
+
+namespace app\controllers;
+
+use Yii;
+use yii\web\Controller;
+use yii\filters\VerbFilter;
+use yii\filters\AccessControl;
+use yii\web\Response;
+use yii_app\services\ShiftReminderService;
+
+/**
+ * ShiftReminderController
+ *
+ * Контроллер для обработки запросов системы напоминаний о сменах.
+ * Предоставляет API для проверки необходимости показа напоминаний и подтверждения их просмотра.
+ *
+ * Endpoints:
+ * - POST /shift-reminder/check - Проверить, нужно ли показать напоминание
+ * - POST /shift-reminder/confirm - Подтвердить просмотр напоминания
+ */
+class ShiftReminderController extends Controller
+{
+ /**
+ * Хранилище для отслеживания rate limiting
+ * @var array
+ */
+ private static $rateLimitStorage = [];
+
+ /**
+ * {@inheritdoc}
+ */
+ public function behaviors()
+ {
+ return [
+ 'verbs' => [
+ 'class' => VerbFilter::class,
+ 'actions' => [
+ 'check' => ['POST'],
+ 'confirm' => ['POST'],
+ ],
+ ],
+ 'access' => [
+ 'class' => AccessControl::class,
+ 'rules' => [
+ [
+ 'allow' => true,
+ 'roles' => ['@'], // Только авторизованные пользователи
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function beforeAction($action)
+ {
+ if (!parent::beforeAction($action)) {
+ return false;
+ }
+
+ // Rate limiting
+ $limits = [
+ 'check' => 10, // 10 запросов в минуту
+ 'confirm' => 5, // 5 запросов в минуту
+ ];
+
+ $actionId = $action->id;
+ if (isset($limits[$actionId])) {
+ if (!$this->checkRateLimit($actionId, $limits[$actionId])) {
+ Yii::$app->response->statusCode = 429;
+ Yii::$app->response->format = Response::FORMAT_JSON;
+ Yii::$app->response->data = [
+ 'success' => false,
+ 'error' => 'Rate limit exceeded. Please try again later.',
+ ];
+ return false;
+ }
+ }
+
+ // Устанавливаем формат ответа JSON для всех действий
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ return true;
+ }
+
+ /**
+ * Проверить rate limit для действия
+ *
+ * @param string $action Название действия
+ * @param int $limit Максимальное количество запросов в минуту
+ * @return bool true если лимит не превышен, false в противном случае
+ */
+ private function checkRateLimit($action, $limit)
+ {
+ $userId = Yii::$app->user->id;
+ $key = "shift_reminder_{$action}_{$userId}";
+ $now = time();
+
+ // Инициализируем хранилище для пользователя, если его нет
+ if (!isset(self::$rateLimitStorage[$key])) {
+ self::$rateLimitStorage[$key] = [];
+ }
+
+ // Удаляем запросы старше 60 секунд
+ self::$rateLimitStorage[$key] = array_filter(
+ self::$rateLimitStorage[$key],
+ function ($timestamp) use ($now) {
+ return ($now - $timestamp) < 60;
+ }
+ );
+
+ // Проверяем, не превышен ли лимит
+ if (count(self::$rateLimitStorage[$key]) >= $limit) {
+ return false;
+ }
+
+ // Добавляем текущий запрос
+ self::$rateLimitStorage[$key][] = $now;
+
+ return true;
+ }
+
+ /**
+ * Проверить, нужно ли показать напоминание пользователю
+ *
+ * POST /shift-reminder/check
+ *
+ * Запрос:
+ * {
+ * "reminder_key": "day_shift" // опционально, по умолчанию "day_shift"
+ * }
+ *
+ * Ответ (успех):
+ * {
+ * "success": true,
+ * "show_reminder": true,
+ * "reminder_key": "day_shift",
+ * "reference_date": "2024-01-27"
+ * }
+ *
+ * Ответ (не нужно показывать):
+ * {
+ * "success": true,
+ * "show_reminder": false
+ * }
+ *
+ * @return array
+ */
+ public function actionCheck()
+ {
+ try {
+ $request = Yii::$app->request;
+ $userId = Yii::$app->user->id;
+
+ // Получаем reminder_key из запроса или используем дефолтное значение
+ $reminderKey = $request->post('reminder_key', 'day_shift');
+
+ // Валидация reminder_key
+ if (!ShiftReminderService::isValidReminderKey($reminderKey)) {
+ return [
+ 'success' => false,
+ 'error' => 'Invalid reminder_key. Allowed values: ' . implode(', ', ShiftReminderService::getAllowedReminderKeys()),
+ ];
+ }
+
+ // Определяем автоматически правильный reminder_key на основе текущего периода
+ // Периоды смен:
+ // 07:50 - 08:09: закрытие ночной смены (night_shift)
+ // 08:10 - 19:49: дневная смена активна (day_shift)
+ // 19:50 - 20:09: закрытие дневной смены (day_shift)
+ // 20:10 - 07:49: ночная смена активна (night_shift)
+ $currentTime = date('H:i');
+ if ($currentTime >= '07:50' && $currentTime < '08:10') {
+ // Закрытие ночной смены
+ $reminderKey = 'night_shift';
+ } elseif ($currentTime >= '08:10' && $currentTime < '19:50') {
+ // Дневная смена активна
+ $reminderKey = 'day_shift';
+ } elseif ($currentTime >= '19:50' && $currentTime < '20:10') {
+ // Закрытие дневной смены
+ $reminderKey = 'day_shift';
+ } else {
+ // Ночная смена активна (20:10 - 07:49)
+ $reminderKey = 'night_shift';
+ }
+
+ // Проверяем, нужно ли показать напоминание
+ $shouldShow = ShiftReminderService::shouldShowReminder($userId, $reminderKey);
+
+ if ($shouldShow) {
+ return [
+ 'success' => true,
+ 'show_reminder' => true,
+ 'reminder_key' => $reminderKey,
+ 'reference_date' => ShiftReminderService::getReferenceDate(),
+ ];
+ }
+
+ return [
+ 'success' => true,
+ 'show_reminder' => false,
+ ];
+ } catch (\Exception $e) {
+ Yii::error('Error in ShiftReminderController::actionCheck: ' . $e->getMessage(), 'shift-reminder');
+
+ return [
+ 'success' => false,
+ 'error' => 'An error occurred while checking reminder status.',
+ ];
+ }
+ }
+
+ /**
+ * Подтвердить просмотр напоминания
+ *
+ * POST /shift-reminder/confirm
+ *
+ * Запрос:
+ * {
+ * "reminder_key": "day_shift"
+ * }
+ *
+ * Ответ (успех):
+ * {
+ * "success": true,
+ * "message": "Reminder confirmed successfully.",
+ * "reference_date": "2024-01-27"
+ * }
+ *
+ * Ответ (ошибка):
+ * {
+ * "success": false,
+ * "error": "Error message"
+ * }
+ *
+ * @return array
+ */
+ public function actionConfirm()
+ {
+ try {
+ $request = Yii::$app->request;
+ $userId = Yii::$app->user->id;
+
+ // Получаем reminder_key из запроса
+ $reminderKey = $request->post('reminder_key');
+
+ // Валидация входных данных
+ if (empty($reminderKey)) {
+ return [
+ 'success' => false,
+ 'error' => 'reminder_key is required.',
+ ];
+ }
+
+ // Валидация reminder_key
+ if (!ShiftReminderService::isValidReminderKey($reminderKey)) {
+ return [
+ 'success' => false,
+ 'error' => 'Invalid reminder_key. Allowed values: ' . implode(', ', ShiftReminderService::getAllowedReminderKeys()),
+ ];
+ }
+
+ // Подтверждаем напоминание
+ $success = ShiftReminderService::confirmReminder($userId, $reminderKey);
+
+ if ($success) {
+ $referenceDate = ShiftReminderService::getReferenceDate();
+
+ // Логируем подтверждение для аудита
+ Yii::info(
+ "User {$userId} confirmed reminder '{$reminderKey}' for reference date {$referenceDate}",
+ 'shift-reminder'
+ );
+
+ return [
+ 'success' => true,
+ 'message' => 'Reminder confirmed successfully.',
+ 'reference_date' => $referenceDate,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'error' => 'Failed to confirm reminder. Please try again.',
+ ];
+ } catch (\Exception $e) {
+ Yii::error('Error in ShiftReminderController::actionConfirm: ' . $e->getMessage(), 'shift-reminder');
+
+ return [
+ 'success' => false,
+ 'error' => 'An error occurred while confirming reminder.',
+ ];
+ }
+ }
+}
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Class m260127_105454_create_shift_reminder_shown_table
+ */
+class m260127_105454_create_shift_reminder_shown_table extends Migration
+{
+ const TABLE_NAME = 'erp24.shift_reminder_shown';
+ /**
+ * {@inheritdoc}
+ */
+ public function safeUp()
+ {
+ $this->createTable(self::TABLE_NAME, [
+ 'id' => $this->primaryKey(),
+ 'user_id' => $this->integer()->notNull()->comment('ID пользователя, которому было показано напоминание'),
+ 'reminder_key' => $this->string(50)->notNull()->comment('Ключ напоминания (идентификатор типа напоминания)'),
+ 'reference_date' => $this->date()->notNull()->comment('Календарная дата, к которой относится напоминание (для корректной обработки переходов через полночь)'),
+ 'confirmed_at' => $this->timestamp()->null()->comment('Дата и время подтверждения напоминания пользователем'),
+ 'created_at' => $this->timestamp()->notNull()->defaultExpression('CURRENT_TIMESTAMP')->comment('Дата создания записи'),
+ ]);
+
+ // Создаем уникальный составной индекс для предотвращения дублирования напоминаний
+ $this->createIndex(
+ 'idx-shift_reminder_shown-user_reminder_date',
+ self::TABLE_NAME,
+ ['user_id', 'reminder_key', 'reference_date'],
+ true
+ );
+
+ // Создаем индекс для быстрого поиска по дате подтверждения
+ $this->createIndex(
+ 'idx-shift_reminder_shown-confirmed_at',
+ self::TABLE_NAME,
+ 'confirmed_at'
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function safeDown()
+ {
+ $this->dropTable(self::TABLE_NAME);
+ }
+}
--- /dev/null
+<?php
+
+namespace app\models;
+
+use Yii;
+use yii\db\ActiveRecord;
+
+/**
+ * ShiftReminderShown ActiveRecord model
+ *
+ * Модель для отслеживания показанных и подтвержденных напоминаний о сменах.
+ * Использует reference_date для корректной обработки переходов через полночь.
+ *
+ * @property int $id
+ * @property int $user_id ID пользователя, которому было показано напоминание
+ * @property string $reminder_key Ключ напоминания (идентификатор типа напоминания)
+ * @property string $reference_date Календарная дата, к которой относится напоминание
+ * @property string|null $confirmed_at Дата и время подтверждения напоминания пользователем
+ * @property string $created_at Дата создания записи
+ *
+ * @package app\models
+ */
+class ShiftReminderShown extends ActiveRecord
+{
+ /**
+ * @inheritdoc
+ */
+ public static function tableName()
+ {
+ return 'shift_reminder_shown';
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function rules()
+ {
+ return [
+ [['user_id', 'reminder_key', 'reference_date'], 'required'],
+ [['user_id'], 'integer'],
+ [['reference_date', 'confirmed_at', 'created_at'], 'safe'],
+ [['reminder_key'], 'string', 'max' => 50],
+ [['reference_date'], 'date', 'format' => 'php:Y-m-d'],
+ ];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function attributeLabels()
+ {
+ return [
+ 'id' => 'ID',
+ 'user_id' => 'User ID',
+ 'reminder_key' => 'Reminder Key',
+ 'reference_date' => 'Reference Date',
+ 'confirmed_at' => 'Confirmed At',
+ 'created_at' => 'Created At',
+ ];
+ }
+
+ /**
+ * Проверяет, было ли напоминание подтверждено пользователем
+ *
+ * @return bool true если напоминание подтверждено, false в противном случае
+ */
+ public function isConfirmed()
+ {
+ return $this->confirmed_at !== null;
+ }
+
+ /**
+ * Отмечает напоминание как подтвержденное и сохраняет запись
+ *
+ * Устанавливает текущее время в поле confirmed_at и сохраняет модель.
+ *
+ * @return bool true если сохранение прошло успешно, false в противном случае
+ */
+ public function markAsConfirmed()
+ {
+ $this->confirmed_at = date('Y-m-d H:i:s');
+ return $this->save(false);
+ }
+}
--- /dev/null
+<?php
+
+namespace yii_app\services;
+
+use Yii;
+use app\models\ShiftReminderShown;
+use yii_app\records\Admin;
+use yii_app\records\Timetable;
+use yii_app\records\TimetableFactModel;
+
+/**
+ * ShiftReminderService
+ *
+ * Сервис для управления напоминаниями о сменах с корректной обработкой перехода через полночь.
+ * Критическая особенность: время 00:00-06:00 относится к предыдущему календарному дню,
+ * чтобы подтверждение в 01:00 не блокировало показ напоминания в 07:50 того же календарного дня.
+ */
+class ShiftReminderService
+{
+ /**
+ * Получить reference_date для текущего времени с учетом логики перехода дня
+ *
+ * Логика day rollover:
+ * - Время 00:00-05:59 → reference_date = предыдущий календарный день
+ * - Время 06:00-23:59 → reference_date = текущий календарный день
+ *
+ * Это гарантирует, что подтверждение напоминания в 01:00 (которое относится к предыдущему дню)
+ * не блокирует показ напоминания в 07:50 (которое относится к текущему дню).
+ *
+ * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+ * @return string Дата в формате 'Y-m-d'
+ */
+ public static function getReferenceDate($currentTime = null)
+ {
+ if ($currentTime === null) {
+ $currentTime = date('Y-m-d H:i:s');
+ }
+
+ $dateTime = new \DateTime($currentTime);
+ $hour = (int)$dateTime->format('H');
+
+ // Если время между 00:00 и 05:59, используем предыдущий день
+ if ($hour >= 0 && $hour < 6) {
+ $dateTime->modify('-1 day');
+ }
+
+ return $dateTime->format('Y-m-d');
+ }
+
+ /**
+ * Проверить, находится ли текущее время в одном из временных окон для показа напоминаний
+ *
+ * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+ * @param array|null $windows Массив временных окон в формате ['HH:MM', ...]. Если null, используются окна из конфигурации.
+ * @return bool true если текущее время находится в одном из окон, false в противном случае
+ */
+ public static function isInTimeWindow($currentTime = null, $windows = null)
+ {
+ if ($currentTime === null) {
+ $currentTime = date('Y-m-d H:i:s');
+ }
+
+ if ($windows === null) {
+ $windows = Yii::$app->params['SHIFT_REMINDER_TIME_WINDOWS'] ?? ['07:50', '08:10', '19:50', '20:10'];
+ }
+
+ $dateTime = new \DateTime($currentTime);
+ $currentTimeStr = $dateTime->format('H:i');
+
+ foreach ($windows as $window) {
+ // Проверяем точное совпадение времени (минута в минуту)
+ if ($currentTimeStr === $window) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Определить, нужно ли показать напоминание пользователю
+ *
+ * Проверяет следующие условия:
+ * 1. Действие со сменой ещё не выполнено в timetable_fact
+ * 2. Напоминание не было подтверждено для текущего reminder_key и reference_date
+ * 3. Пользователь принадлежит к группе администраторов (проверка через timetable)
+ *
+ * Напоминание показывается в любое время периода, пока не подтверждено.
+ * Контроллер определяет правильный reminder_key на основе текущего времени.
+ *
+ * @param int $userId ID пользователя
+ * @param string $reminderKey Ключ напоминания (day_shift или night_shift)
+ * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+ * @return bool true если нужно показать напоминание, false в противном случае
+ */
+ public static function shouldShowReminder($userId, $reminderKey, $currentTime = null)
+ {
+ // Проверка 1: Проверяем статус смены в timetable_fact
+ // Если действие уже выполнено (смена открыта/закрыта) - не показываем напоминание
+ if (!self::isShiftActionNeeded($userId, $currentTime)) {
+ return false;
+ }
+
+ // Проверка 2: Проверяем, не было ли уже подтверждено напоминание для этого reference_date
+ $referenceDate = self::getReferenceDate($currentTime);
+
+ $existingReminder = ShiftReminderShown::find()
+ ->where([
+ 'user_id' => $userId,
+ 'reminder_key' => $reminderKey,
+ 'reference_date' => $referenceDate,
+ ])
+ ->andWhere(['IS NOT', 'confirmed_at', null])
+ ->one();
+
+ if ($existingReminder !== null) {
+ return false;
+ }
+
+ // Проверка 3: Пользователь должен быть администратором
+ if (!self::isUserAdministrator($userId, $currentTime)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Проверить, нужно ли выполнить действие со сменой (открытие/закрытие)
+ *
+ * Логика:
+ * - Период закрытия (07:50-08:09, 19:50-20:09): проверяем, есть ли открытая смена для закрытия
+ * - Период открытия (08:10-19:49, 20:10-07:49): проверяем, не открыта ли уже смена
+ *
+ * @param int $userId ID пользователя
+ * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время.
+ * @return bool true если действие нужно выполнить, false если уже выполнено
+ */
+ public static function isShiftActionNeeded($userId, $currentTime = null)
+ {
+ if ($currentTime === null) {
+ $currentTime = date('Y-m-d H:i:s');
+ }
+
+ $dateTime = new \DateTime($currentTime);
+ $currentTimeStr = $dateTime->format('H:i');
+ $referenceDate = self::getReferenceDate($currentTime);
+
+ // Определяем, это период закрытия или открытия
+ // Периоды закрытия: 07:50-08:09 (закрытие ночной), 19:50-20:09 (закрытие дневной)
+ $isClosingPeriod = ($currentTimeStr >= '07:50' && $currentTimeStr < '08:10')
+ || ($currentTimeStr >= '19:50' && $currentTimeStr < '20:10');
+
+ // Получаем последнюю открытую смену пользователя
+ $openShift = TimetableFactModel::getLast($userId, $referenceDate, true);
+
+ if ($isClosingPeriod) {
+ // Период закрытия: нужно напомнить, если есть открытая смена (is_opening=true, is_close=false)
+ if ($openShift === null) {
+ // Нет открытой смены - нечего закрывать
+ return false;
+ }
+ if ($openShift->is_close) {
+ // Смена уже закрыта
+ return false;
+ }
+ // Есть открытая смена, которую нужно закрыть
+ return true;
+ } else {
+ // Период открытия: нужно напомнить, если смена ещё не открыта
+ if ($openShift !== null && !$openShift->is_close) {
+ // Смена уже открыта и не закрыта - не нужно напоминание об открытии
+ return false;
+ }
+ // Смена не открыта - нужно напомнить об открытии
+ return true;
+ }
+ }
+
+ /**
+ * Проверить, является ли пользователь администратором на текущую дату
+ *
+ * Интеграция с системой timetable: проверяем, есть ли у пользователя
+ * запланированная рабочая смена на reference_date.
+ *
+ * @param int $userId ID пользователя
+ * @param string|null $currentTime Текущее время. Если null, используется текущее время.
+ * @return bool true если пользователь является администратором, false в противном случае
+ */
+ public static function isUserAdministrator($userId, $currentTime = null)
+ {
+ // Получаем администратора
+ $admin = Admin::findOne($userId);
+ if ($admin === null) {
+ return false;
+ }
+
+ // Проверяем, принадлежит ли пользователь к административной группе
+ // Группы администраторов обычно имеют определенные ID
+ // Получаем их из конфигурации или используем дефолтные значения
+ $adminGroupIds = Yii::$app->params['SHIFT_REMINDER_ADMIN_GROUP_IDS'] ?? [];
+
+ if (!empty($adminGroupIds) && !in_array($admin->group_id, $adminGroupIds)) {
+ return false;
+ }
+
+ // Дополнительная проверка: есть ли у пользователя смена в расписании на reference_date
+ $referenceDate = self::getReferenceDate($currentTime);
+
+ $hasTimetable = Timetable::find()
+ ->where([
+ 'admin_id' => $userId,
+ 'date' => $referenceDate,
+ ])
+ ->andWhere([
+ 'slot_type_id' => [
+ 1, // Timetable::TIMESLOT_WORK - работа
+ 5, // Timetable::TIMESLOT_INTERNSHIP - стажировка
+ 8 // Другой тип административной работы
+ ],
+ ])
+ ->andWhere(['tabel' => 0])
+ ->exists();
+
+ return $hasTimetable;
+ }
+
+ /**
+ * Создать или получить запись о показанном напоминании
+ *
+ * @param int $userId ID пользователя
+ * @param string $reminderKey Ключ напоминания
+ * @param string|null $currentTime Текущее время. Если null, используется текущее время.
+ * @return ShiftReminderShown|null Модель напоминания или null в случае ошибки
+ */
+ public static function createReminderRecord($userId, $reminderKey, $currentTime = null)
+ {
+ $referenceDate = self::getReferenceDate($currentTime);
+
+ // Проверяем, существует ли уже запись
+ $reminder = ShiftReminderShown::find()
+ ->where([
+ 'user_id' => $userId,
+ 'reminder_key' => $reminderKey,
+ 'reference_date' => $referenceDate,
+ ])
+ ->one();
+
+ if ($reminder !== null) {
+ return $reminder;
+ }
+
+ // Создаем новую запись
+ $reminder = new ShiftReminderShown();
+ $reminder->user_id = $userId;
+ $reminder->reminder_key = $reminderKey;
+ $reminder->reference_date = $referenceDate;
+
+ if (!$reminder->save()) {
+ Yii::error('Failed to create shift reminder record: ' . json_encode($reminder->errors), 'shift-reminder');
+ return null;
+ }
+
+ return $reminder;
+ }
+
+ /**
+ * Подтвердить напоминание
+ *
+ * @param int $userId ID пользователя
+ * @param string $reminderKey Ключ напоминания
+ * @param string|null $currentTime Текущее время. Если null, используется текущее время.
+ * @return bool true если подтверждение прошло успешно, false в противном случае
+ */
+ public static function confirmReminder($userId, $reminderKey, $currentTime = null)
+ {
+ $reminder = self::createReminderRecord($userId, $reminderKey, $currentTime);
+
+ if ($reminder === null) {
+ return false;
+ }
+
+ if ($reminder->isConfirmed()) {
+ // Уже подтверждено
+ return true;
+ }
+
+ return $reminder->markAsConfirmed();
+ }
+
+ /**
+ * Получить список допустимых ключей напоминаний (whitelist)
+ *
+ * @return array Массив допустимых reminder_key
+ */
+ public static function getAllowedReminderKeys()
+ {
+ return Yii::$app->params['SHIFT_REMINDER_ALLOWED_KEYS'] ?? ['day_shift', 'night_shift'];
+ }
+
+ /**
+ * Проверить, является ли ключ напоминания допустимым
+ *
+ * @param string $reminderKey Ключ напоминания для проверки
+ * @return bool true если ключ допустим, false в противном случае
+ */
+ public static function isValidReminderKey($reminderKey)
+ {
+ return in_array($reminderKey, self::getAllowedReminderKeys(), true);
+ }
+}
app\assets\JQueryPluginsAsset::register($this); /** @TODO удалить после переписывания основного функционала */
use yii\widgets\Breadcrumbs;
+
+// Register Shift Reminder JavaScript
+$this->registerJsFile('/js/shift-reminder.js', ['position' => \yii\web\View::POS_END]);
?>
<?php $this->beginPage() ?>
<!DOCTYPE html>
</div>
</div>
+ <!-- Shift Reminder Modal -->
+ <?= $this->render('//shift-reminder/_modal') ?>
+
<?php $this->endBody(); ?>
</body>
</html>
--- /dev/null
+<?php
+/**
+ * Shift Reminder Modal Template
+ *
+ * Fullscreen modal overlay that displays shift reminders to administrators.
+ * This modal blocks all user interaction until confirmed.
+ *
+ * Features:
+ * - Fullscreen overlay (z-index: 99999)
+ * - No close button (user must confirm)
+ * - Dark mode support
+ * - CSRF token included for confirmation
+ * - Accessible via keyboard (Enter key to confirm)
+ *
+ * @var \yii\web\View $this
+ * @var string $message Optional message to display (default provided)
+ * @var string $reminderKey Reminder key (day_shift or night_shift)
+ */
+
+use yii\helpers\Html;
+
+$message = $message ?? 'Пожалуйста, подтвердите, что вы ознакомились с напоминанием о смене.';
+$reminderKey = $reminderKey ?? '';
+?>
+
+<!-- Shift Reminder Fullscreen Modal -->
+<div id="shift-reminder-overlay" class="shift-reminder-overlay" style="display: none;">
+ <div class="shift-reminder-content">
+ <!-- Warning Icon -->
+ <div class="shift-reminder-icon">
+ <svg width="80" height="80" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" fill="currentColor"/>
+ </svg>
+ </div>
+
+ <!-- Title -->
+ <h2 class="shift-reminder-title">Напоминание о смене</h2>
+
+ <!-- Message -->
+ <p class="shift-reminder-message" id="shift-reminder-message-text">
+ <?= Html::encode($message) ?>
+ </p>
+
+ <!-- CSRF Token (hidden) -->
+ <?= Html::hiddenInput(
+ Yii::$app->request->csrfParam,
+ Yii::$app->request->csrfToken,
+ ['id' => 'shift-reminder-csrf-token']
+ ) ?>
+
+ <!-- Hidden field for reminder key -->
+ <?= Html::hiddenInput(
+ 'reminder_key',
+ $reminderKey,
+ ['id' => 'shift-reminder-key']
+ ) ?>
+
+ <!-- Confirm Button -->
+ <button
+ id="shift-reminder-confirm-btn"
+ class="btn btn-primary btn-lg"
+ type="button"
+ aria-label="Подтвердить напоминание">
+ Подтвердить
+ </button>
+ </div>
+</div>
+
+<!-- Embedded Styles for Fullscreen Modal -->
+<style>
+ /* Fullscreen overlay */
+ .shift-reminder-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.9);
+ z-index: 99999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: shift-reminder-fade-in 0.3s ease-in;
+ }
+
+ /* Modal content container */
+ .shift-reminder-content {
+ background: #fff;
+ padding: 40px;
+ border-radius: 10px;
+ text-align: center;
+ max-width: 500px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
+ position: relative;
+ }
+
+ /* Dark mode support */
+ .dark-mode .shift-reminder-content {
+ background: #2d3035;
+ color: #fff;
+ }
+
+ /* Warning icon */
+ .shift-reminder-icon {
+ color: #f39c12;
+ margin-bottom: 20px;
+ }
+
+ .dark-mode .shift-reminder-icon {
+ color: #f39c12;
+ }
+
+ /* Title styling */
+ .shift-reminder-title {
+ font-size: 24px;
+ font-weight: bold;
+ margin-bottom: 20px;
+ color: #333;
+ }
+
+ .dark-mode .shift-reminder-title {
+ color: #fff;
+ }
+
+ /* Message text */
+ .shift-reminder-message {
+ font-size: 16px;
+ margin-bottom: 30px;
+ line-height: 1.5;
+ color: #666;
+ }
+
+ .dark-mode .shift-reminder-message {
+ color: #ccc;
+ }
+
+ /* Confirm button */
+ #shift-reminder-confirm-btn {
+ font-size: 18px;
+ padding: 12px 40px;
+ min-width: 200px;
+ cursor: pointer;
+ border: none;
+ transition: all 0.2s ease;
+ }
+
+ #shift-reminder-confirm-btn:hover {
+ transform: scale(1.05);
+ }
+
+ #shift-reminder-confirm-btn:active {
+ transform: scale(0.98);
+ }
+
+ #shift-reminder-confirm-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ /* Fade-in animation */
+ @keyframes shift-reminder-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+
+ /* Responsive design */
+ @media (max-width: 600px) {
+ .shift-reminder-content {
+ padding: 30px 20px;
+ max-width: 90%;
+ margin: 0 15px;
+ }
+
+ .shift-reminder-title {
+ font-size: 20px;
+ }
+
+ .shift-reminder-message {
+ font-size: 14px;
+ }
+
+ #shift-reminder-confirm-btn {
+ font-size: 16px;
+ padding: 10px 30px;
+ min-width: 150px;
+ }
+
+ .shift-reminder-icon svg {
+ width: 60px;
+ height: 60px;
+ }
+ }
+
+ /* Print: hide the reminder modal */
+ @media print {
+ .shift-reminder-overlay {
+ display: none !important;
+ }
+ }
+</style>
--- /dev/null
+/**
+ * Shift Reminder System
+ *
+ * Polls backend every 30 seconds to check if reminder should be shown.
+ * Displays fullscreen modal with audio alert when reminder is needed.
+ * Implements day rollover logic and multi-tab coordination.
+ */
+
+(function() {
+ 'use strict';
+
+ // CSRF token extraction
+ const csrfParam = $('meta[name=csrf-param]').attr('content');
+ const csrfToken = $('meta[name=csrf-token]').attr('content');
+
+ // Configuration
+ const CONFIG = {
+ pollInterval: 30000, // 30 seconds
+ maxPollInterval: 240000, // 4 minutes (for exponential backoff)
+ retryIntervals: [30000, 60000, 120000, 240000], // Exponential backoff
+ localStorageKey: 'shift_reminder_shown',
+ audioFrequency: 800, // Hz
+ audioDuration: 500 // ms
+ };
+
+ // State management
+ let state = {
+ pollTimer: null,
+ currentInterval: CONFIG.pollInterval,
+ retryCount: 0,
+ isModalVisible: false,
+ audioContext: null,
+ bcChannel: null
+ };
+
+ /**
+ * Initialize the shift reminder system
+ */
+ function init() {
+ // Initialize BroadcastChannel for multi-tab coordination
+ if (typeof BroadcastChannel !== 'undefined') {
+ state.bcChannel = new BroadcastChannel('shift_reminder_channel');
+ state.bcChannel.onmessage = handleBroadcastMessage;
+ }
+
+ // Listen to localStorage changes (fallback for browsers without BroadcastChannel)
+ window.addEventListener('storage', handleStorageChange);
+
+ // Initialize Web Audio API (lazy loading to respect autoplay policies)
+ initAudioContext();
+
+ // Start polling
+ startPolling();
+
+ // Check immediately on load
+ checkReminder();
+ }
+
+ /**
+ * Initialize Web Audio API context
+ */
+ function initAudioContext() {
+ try {
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
+ if (AudioContext) {
+ state.audioContext = new AudioContext();
+ }
+ } catch (e) {
+ console.warn('Web Audio API not supported:', e);
+ }
+ }
+
+ /**
+ * Start polling for reminders
+ */
+ function startPolling() {
+ if (state.pollTimer) {
+ clearInterval(state.pollTimer);
+ }
+
+ state.pollTimer = setInterval(() => {
+ checkReminder();
+ }, state.currentInterval);
+ }
+
+ /**
+ * Stop polling
+ */
+ function stopPolling() {
+ if (state.pollTimer) {
+ clearInterval(state.pollTimer);
+ state.pollTimer = null;
+ }
+ }
+
+ /**
+ * Check if reminder should be shown
+ */
+ function checkReminder() {
+ // Don't check if modal is already visible
+ if (state.isModalVisible) {
+ return;
+ }
+
+ // Check if another tab already handled this reminder
+ if (isReminderHandledByOtherTab()) {
+ return;
+ }
+
+ $.ajax({
+ url: '/shift-reminder/check',
+ type: 'POST',
+ dataType: 'json',
+ data: {
+ [csrfParam]: csrfToken
+ },
+ success: function(response) {
+ // Reset retry count on successful request
+ state.retryCount = 0;
+ state.currentInterval = CONFIG.pollInterval;
+
+ if (response.success && response.show_reminder) {
+ showReminderModal(response);
+ }
+ },
+ error: function(xhr, status, error) {
+ handleRequestError(xhr, status, error);
+ }
+ });
+ }
+
+ /**
+ * Handle AJAX request errors with exponential backoff
+ */
+ function handleRequestError(xhr, status, error) {
+ if (xhr.status === 429) {
+ // Rate limit exceeded - increase polling interval
+ state.currentInterval = Math.min(
+ state.currentInterval * 2,
+ CONFIG.maxPollInterval
+ );
+ stopPolling();
+ startPolling();
+ return;
+ }
+
+ // Network error - implement exponential backoff
+ if (state.retryCount < CONFIG.retryIntervals.length - 1) {
+ state.retryCount++;
+ }
+
+ state.currentInterval = CONFIG.retryIntervals[state.retryCount];
+ stopPolling();
+ startPolling();
+ }
+
+ /**
+ * Check if reminder was already handled by another tab
+ */
+ function isReminderHandledByOtherTab() {
+ try {
+ const stored = localStorage.getItem(CONFIG.localStorageKey);
+ if (!stored) {
+ return false;
+ }
+
+ const data = JSON.parse(stored);
+ const now = new Date().getTime();
+
+ // Check if confirmation was within last 5 minutes
+ if (data.timestamp && (now - data.timestamp) < 300000) {
+ return true;
+ }
+
+ // Clean up old data
+ localStorage.removeItem(CONFIG.localStorageKey);
+ return false;
+ } catch (e) {
+ console.error('Error checking localStorage:', e);
+ return false;
+ }
+ }
+
+ /**
+ * Show fullscreen reminder modal
+ */
+ function showReminderModal(data) {
+ if (state.isModalVisible) {
+ return;
+ }
+
+ state.isModalVisible = true;
+
+ // Create modal HTML
+ const modalHtml = `
+ <div id="shift-reminder-modal" class="shift-reminder-overlay">
+ <div class="shift-reminder-content">
+ <div class="shift-reminder-icon">
+ <i class="fa fa-bell fa-3x"></i>
+ </div>
+ <h2 class="shift-reminder-title">Напоминание о смене</h2>
+ <p class="shift-reminder-message">
+ ${data.message || 'Пожалуйста, подтвердите, что вы ознакомились с напоминанием о смене.'}
+ </p>
+ <button id="shift-reminder-confirm-btn" class="btn btn-primary btn-lg">
+ Подтвердить
+ </button>
+ </div>
+ </div>
+ `;
+
+ // Add modal to DOM
+ $('body').append(modalHtml);
+
+ // Add modal styles
+ addModalStyles();
+
+ // Play audio alert
+ playAudioAlert();
+
+ // Notify other tabs
+ broadcastMessage({
+ type: 'modal_shown',
+ timestamp: new Date().getTime()
+ });
+
+ // Handle confirmation
+ $('#shift-reminder-confirm-btn').on('click', function() {
+ confirmReminder(data.reminder_key);
+ });
+ }
+
+ /**
+ * Add CSS styles for fullscreen modal
+ */
+ function addModalStyles() {
+ if ($('#shift-reminder-styles').length > 0) {
+ return;
+ }
+
+ const styles = `
+ <style id="shift-reminder-styles">
+ .shift-reminder-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.9);
+ z-index: 99999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: shift-reminder-fade-in 0.3s ease-in;
+ }
+
+ .shift-reminder-content {
+ background: #fff;
+ padding: 40px;
+ border-radius: 10px;
+ text-align: center;
+ max-width: 500px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
+ }
+
+ .dark-mode .shift-reminder-content {
+ background: #2d3035;
+ color: #fff;
+ }
+
+ .shift-reminder-icon {
+ color: #f39c12;
+ margin-bottom: 20px;
+ }
+
+ .shift-reminder-title {
+ font-size: 24px;
+ font-weight: bold;
+ margin-bottom: 20px;
+ }
+
+ .shift-reminder-message {
+ font-size: 16px;
+ margin-bottom: 30px;
+ line-height: 1.5;
+ }
+
+ #shift-reminder-confirm-btn {
+ font-size: 18px;
+ padding: 12px 40px;
+ min-width: 200px;
+ }
+
+ @keyframes shift-reminder-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+ </style>
+ `;
+
+ $('head').append(styles);
+ }
+
+ /**
+ * Play audio alert using Web Audio API
+ */
+ function playAudioAlert() {
+ if (!state.audioContext) {
+ console.warn('Audio context not available');
+ return;
+ }
+
+ try {
+ // Resume audio context if suspended (autoplay policy)
+ if (state.audioContext.state === 'suspended') {
+ state.audioContext.resume().then(() => {
+ playBeep();
+ }).catch((e) => {
+ console.warn('Could not resume audio context:', e);
+ });
+ } else {
+ playBeep();
+ }
+ } catch (e) {
+ console.error('Error playing audio alert:', e);
+ }
+ }
+
+ /**
+ * Play beep sound
+ */
+ function playBeep() {
+ try {
+ const oscillator = state.audioContext.createOscillator();
+ const gainNode = state.audioContext.createGain();
+
+ oscillator.connect(gainNode);
+ gainNode.connect(state.audioContext.destination);
+
+ oscillator.frequency.value = CONFIG.audioFrequency;
+ oscillator.type = 'sine';
+
+ gainNode.gain.setValueAtTime(0.3, state.audioContext.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(
+ 0.01,
+ state.audioContext.currentTime + CONFIG.audioDuration / 1000
+ );
+
+ oscillator.start(state.audioContext.currentTime);
+ oscillator.stop(state.audioContext.currentTime + CONFIG.audioDuration / 1000);
+ } catch (e) {
+ console.error('Error playing beep:', e);
+ }
+ }
+
+ /**
+ * Confirm reminder viewing
+ */
+ function confirmReminder(reminderKey) {
+ // Disable button to prevent double-clicks
+ $('#shift-reminder-confirm-btn').prop('disabled', true).text('Подтверждение...');
+
+ $.ajax({
+ url: '/shift-reminder/confirm',
+ type: 'POST',
+ dataType: 'json',
+ data: {
+ reminder_key: reminderKey,
+ [csrfParam]: csrfToken
+ },
+ success: function(response) {
+ if (response.success) {
+ // Store confirmation in localStorage for multi-tab coordination
+ try {
+ localStorage.setItem(CONFIG.localStorageKey, JSON.stringify({
+ reminder_key: reminderKey,
+ timestamp: new Date().getTime()
+ }));
+ } catch (e) {
+ console.error('Error storing confirmation:', e);
+ }
+
+ // Notify other tabs
+ broadcastMessage({
+ type: 'confirmed',
+ reminder_key: reminderKey,
+ timestamp: new Date().getTime()
+ });
+
+ // Close modal
+ closeModal();
+ } else {
+ alert('Ошибка подтверждения: ' + (response.message || 'Неизвестная ошибка'));
+ $('#shift-reminder-confirm-btn').prop('disabled', false).text('Подтвердить');
+ }
+ },
+ error: function(xhr, status, error) {
+ let errorMessage = 'Ошибка при подтверждении. Попробуйте еще раз.';
+
+ if (xhr.status === 400 && xhr.responseJSON && xhr.responseJSON.message) {
+ errorMessage = xhr.responseJSON.message;
+ } else if (xhr.status === 429) {
+ errorMessage = 'Превышен лимит запросов. Пожалуйста, подождите немного.';
+ }
+
+ alert(errorMessage);
+ $('#shift-reminder-confirm-btn').prop('disabled', false).text('Подтвердить');
+ }
+ });
+ }
+
+ /**
+ * Close reminder modal
+ */
+ function closeModal() {
+ $('#shift-reminder-modal').fadeOut(300, function() {
+ $(this).remove();
+ state.isModalVisible = false;
+ });
+ }
+
+ /**
+ * Broadcast message to other tabs
+ */
+ function broadcastMessage(message) {
+ if (state.bcChannel) {
+ try {
+ state.bcChannel.postMessage(message);
+ } catch (e) {
+ console.error('Error broadcasting message:', e);
+ }
+ }
+ }
+
+ /**
+ * Handle messages from other tabs (BroadcastChannel)
+ */
+ function handleBroadcastMessage(event) {
+ const data = event.data;
+
+ if (data.type === 'confirmed' || data.type === 'modal_shown') {
+ // Another tab confirmed or showed modal - close ours if visible
+ if (state.isModalVisible) {
+ closeModal();
+ }
+ }
+ }
+
+ /**
+ * Handle localStorage changes (fallback for browsers without BroadcastChannel)
+ */
+ function handleStorageChange(event) {
+ if (event.key === CONFIG.localStorageKey && event.newValue) {
+ // Another tab confirmed - close our modal if visible
+ if (state.isModalVisible) {
+ closeModal();
+ }
+ }
+ }
+
+ /**
+ * Cleanup on page unload
+ */
+ function cleanup() {
+ stopPolling();
+
+ if (state.bcChannel) {
+ state.bcChannel.close();
+ }
+
+ if (state.audioContext) {
+ state.audioContext.close();
+ }
+ }
+
+ // Initialize on DOM ready
+ $(document).ready(function() {
+ init();
+ });
+
+ // Cleanup on page unload
+ $(window).on('beforeunload', function() {
+ cleanup();
+ });
+
+})();