尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
Дашборд для анализа задач, синхронизированных из Jira Cloud. Сервер поднимется на http://localhost:5000. На macOS порт 5000 может быть занят системным AirPlay Receiver (ControlCenter). Если при старте видите EADDRINUSE, либо отключите AirPlay Receiver в System Settings → General → AirDrop & Handoff, либо запустите backend с другим портом: PORT=5050 npm start (и поменяйте /api proxy в frontend/vite.config.js со檢視完整 README ↗
Дашборд для анализа задач, синхронизированных из Jira Cloud.
ai-delivery-copilot/
├── backend/ # Node.js + Express API
│ ├── server.js
│ ├── db.js # Postgres pool + schema creation
│ ├── package.json
│ ├── lib/
│ │ ├── jiraAuth.js # token storage + refresh
│ │ └── problemIssues.js # блокеры/aging-WIP, shared by the Dashboard widget + Задачи filter
│ └── routes/
│ ├── auth.js # GET /api/auth/login, /api/auth/callback (Jira OAuth)
│ ├── jira.js # sync/status/issues/fields/field-mapping/tasks/team-roles/wip-limits
│ ├── teams.js # GET /api/teams, /api/teams/filters
│ ├── sprints.js # GET /api/sprints/report, /api/sprints/filters
│ ├── dashboard.js # GET/POST /api/dashboard/widgets, GET /api/dashboard/attention
│ └── reports.js # GET /api/reports/status, POST /api/reports/summary, POST /api/reports/export
├── frontend/ # React + Vite + Tailwind
│ ├── package.json
│ ├── index.html
│ ├── src/
│ │ ├── main.jsx
│ │ ├── App.jsx
│ │ ├── api.js
│ │ ├── index.css
│ │ ├── useSharedFilters.js
│ │ └── components/
│ │ ├── TopNav.jsx
│ │ ├── Dashboard.jsx
│ │ ├── WidgetDrilldown.jsx
│ │ ├── Tasks.jsx
│ │ ├── Teams.jsx
│ │ ├── Sprints.jsx
│ │ ├── TaskDetailPanel.jsx
│ │ ├── FilterBar.jsx
│ │ ├── MultiSelectFilter.jsx
│ │ ├── StatusBadge.jsx
│ │ ├── Settings.jsx
│ │ ├── TeamRoles.jsx
│ │ ├── WipLimitsModal.jsx
│ │ ├── WidgetMenu.jsx
│ │ ├── WidgetLibraryModal.jsx
│ │ ├── AttentionWidget.jsx
│ │ ├── Reports.jsx
│ │ ├── SyncHistory.jsx
│ │ ├── JiraPanel.jsx
│ │ └── FieldMapping.jsx
│ └── public/
│ └── index.html
└── README.md
cd backend
npm install
npm start
Сервер поднимется на http://localhost:5000.
На macOS порт 5000 может быть занят системным AirPlay Receiver (ControlCenter). Если при старте видите
EADDRINUSE, либо отключите AirPlay Receiver в System Settings → General → AirDrop & Handoff, либо запустите backend с другим портом:PORT=5050 npm start(и поменяйте/apiproxy вfrontend/vite.config.jsсоответственно).
cd frontend
npm install
npm run dev
Приложение поднимется на http://localhost:3000 (запросы к /api/* проксируются на backend).
frontend/public/index.html — статический шаблон по требованиям структуры; фактический entry point для Vite — frontend/index.html.backend/server.js экспортирует app (Express-приложение), а app.listen() вызывается только при локальном запуске (require.main === module) — при импорте Vercel как serverless-функции listen не выполняется.
backend/vercel.json направляет все запросы в server.js через @vercel/node.
Настройки в панели Vercel: Root Directory — backend, Framework Preset — Other.
Авторизация — GET /api/auth/login редиректит пользователя на страницу согласия Jira (auth.atlassian.com/authorize), защищено CSRF-параметром state в HttpOnly-куке. GET /api/auth/callback обменивает код на access_token/refresh_token, запрашивает cloud_id сайта через accessible-resources, сохраняет токены в таблице jira_tokens (одна активная запись, при повторном подключении перезаписывается) и редиректит обратно на фронтенд с ?jira=connected (по умолчанию — https://ai-delivery-copilot.vercel.app, переопределяется через FRONTEND_URL). Фронтенд (App.jsx) читает этот параметр при первом рендере, сразу показывает статус «подключено» и сообщение «Jira успешно подключена», затем убирает параметр из URL через history.replaceState и всё равно уточняет статус через GET /api/jira/status.
Синхронизация — POST /api/jira/sync берёт актуальный access token (автоматически обновляя его через refresh_token, если истёк), запрашивает issues проекта SCRUM через POST .../rest/api/3/search/jql (актуальный endpoint — старый /rest/api/3/search Atlassian удалила, он отвечает 410 Gone) и для каждой issue делает UPSERT в таблицу issues: если issue_key уже есть — сравнивает поля status, assignee, sprint, priority со старыми значениями и при изменении пишет запись в issue_history, затем обновляет строку; если issue новая — создаёт запись. Существующие записи никогда не удаляются автоматически (is_deleted зарезервировано для будущей логики). last_synced_at обновляется для каждой обработанной issue. Пагинация — курсорная (nextPageToken/isLast), а не startAt/total, как было в старом API; fetchAllIssues в routes/jira.js листает страницы, пока не встретит isLast: true или пустой nextPageToken.
Заменяет прежнюю заглушку «Скоро». Считается в той же computeTeamsReport() (routes/teams.js), что и остальной экран «Команды», из уже имеющейся таблицы issue_links (заведена ранее для «блокеров» в «Спринтах»/«Требует внимания» — отдельной миграции для этой задачи не потребовалось).
is blocked by, где команда заблокированной задачи и команда задачи-блокера различаются; связь, у которой блокирующая задача уже status_category = done, считается неактивной и не учитывается.created_at блокирующей задачи (то же допущение, что и у остальных «блокерных» расчётов в приложении: issue_links пересобирается заново при каждом sync, отдельного «заблокирован с такого-то момента» не хранится).blockersCount в основной таблице).Четыре фиксированных шаблона, переключаемых через сайдбар «Шаблоны» (Reports.jsx — просто локальный useState, без сохранения истории: каждый рендерится заново при выборе). Блока «Мои отчёты» (сохранённые версии) нет — отдельная задача на будущее.
GET /api/reports/status считает весь отчёт заново на каждый запрос: период — из общего фильтра «Период» (если он «Всё время», берутся последние 7 дней), Проект/Команда/Тип/Статус/Приоритет — из того же общего набора фильтров, что и Дашборд/Задачи.computeTeamsReport() вынесена из routes/teams.js в отдельную функцию (расширенную типом/статусом/приоритетом фильтрами, которых у самого экрана «Команды» нет) и импортируется в reports.js, чтобы План/Факт SP и health-статус команды не могли разойтись между двумя экранами. Комментарий в таблице — производная от 3-сигнальной health-модели (перегруз/риск/без рисков/недостаточно данных) плюс % багов.computeProblemIssues(), тот же движок, что у виджета «Требует внимания»), но полностью редактируется вручную в textarea. Правка сохраняется в report_summary_draft, ключ — periodKey (диапазон дат + выбранные проекты), так что у разных периодов/проектов свои черновики и они не перетирают друг друга.story_points завершённых задач по неделям (8 недель по умолчанию, не настраивается), последняя (текущая, возможно неполная) неделя отрисовывается серым баром вместо синего.POST /api/reports/export собирает файл из уже отрендеренных на экране данных (резюме + обе таблицы), а не пересчитывает их заново — экспорт не может разойтись с тем, что видит пользователь.«Итоги спринта» (GET /api/reports/sprint-summary, SprintSummaryReport.jsx) — переиспользует computeSprintReport(), вынесенную из routes/sprints.js (тот же рефакторинг-паттерн, что и computeTeamsReport), включая её burndown/forecast/scope-creep математику и новое поле selectedIssues (сырые строки выбранного спринта — нужны только отчёту, сам экран «Спринты» читает лишь агрегаты). Дефолтный спринт для отчёта — последний завершённый, а не активный (в отличие от самого экрана «Спринты», где по умолчанию активный) — это два разных, намеренно разных дефолта. KPI-тренды считаются относительно среднего по предыдущим 2-3 спринтам (та же ±10% граница good/risk, что и везде в приложении). «Scope creep %» — та же метрика (доля СЧЁТА задач, а не SP), что уже показана на экране «Спринты», не отдельная. «Перенесённые задачи» для активного спринта — тот же список «ещё не Done», что и для закрытого, просто с другой подписью («прогноз» вместо «не завершены») — по спринту нет вероятностной модели по каждой задаче, а агрегатный прогноз (forecastPct/lagSp) уже есть в KPI. «Топ-3 самых долгих» — единая метрика duration (cycle_time для done, иначе today−started_at), задачи без обоих — исключены, а не приравнены к нулю.
«Качество и баги» (GET /api/reports/quality, QualityReport.jsx) — период по умолчанию 8 недель (56 дней), если общий фильтр «Период» = «Всё время» (та же схема, что у DEFAULT_PERIOD_DAYS в «Статусе доставки»). «% багов по неделям» бакетируется по created_at (та же логика бакетинга по дате события, что у Throughput/«Динамики доставки» на дашборде, а не «сколько багов было открыто на тот момент»). «Топ задач по количеству багов» — рабочее предположение подтверждено перед реализацией: issue_links хранит связи ЛЮБОГО типа (не только «blocked by» — extractIssueLinks в routes/jira.js сохраняет весь issuelinks без фильтрации), так что баг считается привязанным к задаче через связь любого link_type между Bug и не-Bug; поскольку Jira отражает связь с обеих сторон, пары дедуплицируются через Set до подсчёта.
«Здоровье команд» (сайдбар называет его «Квартальный обзор», как в макете) (GET /api/reports/teams-health, TeamsHealthReport.jsx) — целиком переиспользует computeTeamsReport(): health-статус, сработавшие сигналы (WIP/Cycle Time/Velocity) и peopleLoad берутся оттуда без пересчёта, только сплющиваются в общую таблицу по всем командам. «Тренд Velocity» использует doneSp (сделано, а не взято) из sprintChart по последним закрытым спринтам; тренд = отношение среднего по последним 2 к среднему по предыдущим (до 3), с той же ±10% границей.
Не реализован пункт 3.5 («Изменения с прошлого периода») — явно оговоренный в задаче как «пропусти, если технически сложно, не упрощай». WIP-сигнал health-модели — это всегда живой снимок текущих задач (сколько сейчас indeterminate), у которого нет исторической записи; чтобы посчитать «здоровье на конец прошлого периода», пришлось бы строить параллельный, параметризованный по произвольной дате движок (WIP — реконструкция по issue_status_events, спринты — фильтр по complete_date, cycle time — сдвинутое окно), который рискует незаметно разойтись с живым расчётом в «Командах» — а в этом проекте принципиально ни разу не допускалось, чтобы два экрана считали одно и то же по-разному. Сообщение об этом показано прямо на экране отчёта.
GET /api/dashboard/teams-summary) — Всего/В работе/Готово/Cycle по каждой команде + бейдж «Тренд». Тренд сравнивает средний cycle time команды за ТЕКУЩИЙ период (Период дашборда, либо 30 дней по умолчанию, если он «Всё время» — та же эвристика, что и у сигнала «Отставание cycle time» в «Командах») с её ЖЕ историческим (всё время) средним: лучше >10% → «хорошо», хуже на 10-30% → «риск», хуже >30% ИЛИ SP взятых в текущем спринте выше среднего за 3 предыдущих закрытых спринта более чем на 10% → «перегруз» (SP-сигнал переиспользует computeTeamsReport() из «Команд», чтобы не считать историю спринтов дважды). Меньше 3 завершённых задач в периоде — бейдж не показывается (прочерк). Первые 4 команды на дашборде, «Показать все →» открывает виджет на весь экран.GET /api/dashboard/throughput) — количество задач, перешедших в done-категорию, по календарным неделям (пн-вс, из issue_status_events — последний переход в done, а не resolved_at, на случай reopen+повторного закрытия), последние 7 недель, подписи столбцов — только рабочие дни (пн-пт). Текущая (незавершённая) неделя — серый столбец. Осознанно игнорирует фильтр «Период» дашборда — 7-недельный тренд и, скажем, «последние 7 дней» логически конфликтуют, а обрезка тренда до Периода свела бы виджет к одному-двум столбцам; Проект/Команда/Тип/Статус/Приоритет по-прежнему применяются.GET /api/sprints/report (тот же эндпоинт, что и экран «Спринты») и общий компонент BurndownChart.jsx (вынесен из Sprints.jsx), так что математика гарантированно не может разойтись между экраном и виджетом. Селектор спринта по умолчанию — активный, иначе последний закрытый. Только в этом виджете (не на экране «Спринты», который остался однoспринтовым без изменений) селектор — мультиселект: при выборе 2+ спринтов виджет переключается в режим сравнения (MultiSprintBurndownChart.jsx) — по одному GET-запросу к /api/sprints/report на каждый выбранный спринт (параллельно), без изменений самого эндпоинта. Ось X — собственный относительный день каждого спринта (dayIndex из ответа, уже был относительным), поэтому спринты с разными датами старта сопоставимы. Линия «факт» каждого спринта нормализована к % от его же взятого объёма (actualSp / kpis.takenSp × 100), а не к абсолютным SP — иначе спринт с большим объёмом визуально доминировал бы независимо от того, насколько он «на графике». Идеальная линия в режиме сравнения не показывается: единая прямая 100%→0% была бы некорректна для спринтов разной длительности при общей оси «относительный день», а по одной идеальной линии на каждый спринт — ровно то визуальное нагромождение, от которого просили уйти; выбран более простой вариант без идеальных линий, о чём явно сказано пользователю. При ровно одном выбранном спринте поведение полностью прежнее (одна линия факта + идеал + «отставание N SP»).align-items: stretch по умолчанию) — это уравнивало виджеты только ВНУТРИ одной строки, из-за чего разные строки получались разной высоты. Заменено на единую фиксированную высоту для всех виджетов сразу, независимо от строки — DASHBOARD_WIDGET_HEIGHT_CLASS (h-[320px], подобрана по компактному виджету «Throughput по неделям») в dashboardWidgetLayout.js, применяется одинаково во всех 5 виджет-компонентах через widgetHeightClass(fullScreen). Сетка теперь с items-start, чтобы grid-stretch не накладывался поверх. Каждая карточка — flex flex-col с телом flex-1 min-h-0 overflow-y-auto: сама карточка не может вырасти выше фиксированной высоты, скроллится только содержимое, заголовок и иконки управления всегда остаются на месте. В полноэкранном режиме (fullScreen) фиксированная высота не применяется — модалка и так даёт виджету достаточно места.db.js теперь сеет только by_status/by_team/by_type), и с уже существующих дашбордов через одноразовую миграцию (DELETE FROM dashboard_widgets WHERE widget_type = 'stats_cards', идемпотентна, выполняется при каждом ensureSchema()).SectionHeader.jsx — общий заголовок раздела (название + «Последняя синхронизация с Jira» + кнопка обычной синхронизации, самодостаточный по состоянию синхронизации через useSyncProgress), применён на Дашборде/Задачах/Команде/Спринтах/Отчётах. «Полная пересинхронизация» убрана из заголовков и перенесена в Настройки → «Подключение Jira».FilterBar.jsx): Проект/Команда/Тип/Статус/Приоритет/Период — теперь одинаковый на Дашборде/Задачах/Команде/Отчётах (экран «Команды» лишился собственного фильтра «Спринт» и получил вместо него полный общий набор — computeTeamsReport() и так уже сам резолвит «последний спринт» команды, отдельный ручной выбор спринта был избыточен). «Проблема» на Задачах переименована в «Проблемы». Спринты (4.3) — исключение, как и раньше: только Спринт + Команда, отдельное несвязанное состояние (плюс убран лишний фильтр «Тип задачи», который туда когда-то добавили).
PeriodFilter.jsx + ): пресеты (7/30/90 дней/всё время) или свои даты. теперь может быть , тогда / («YYYY-MM-DD») задают границы. — общий бэкенд-резолвер, используется одинаково в (задачи), (Команды), (виджеты «Требует внимания»/«Задачи по командам») и (все отчёты) — так что «Период» не может означать разное в разных местах.Каждое число, которое раньше было захардкожено где-то в бэкенде (порог "зависшей" задачи, множители 3-сигнальной health-модели, границы загрузки людей, порог "зависла на ревью"), теперь хранится в таблице metric_thresholds (key TEXT PRIMARY KEY, value NUMERIC) и правится из Настройки → «Метрики и SLA» — MetricThresholdsSettings.jsx, без отдельной кнопки «Сохранить» (каждое поле сохраняется само, с debounce 500мс, как остальные настройки в проекте).
getThresholds() возвращает {key: number}, подставляя дефолт для любого ключа, которого почему-то нет в таблице (не блокирует остальную логику отсутствующей строкой); setThreshold()/resetThresholds() — точечное изменение и полный сброс к дефолтам.db.js сеет все 9 строк дефолтами при первом запуске (ON CONFLICT DO NOTHING — на существующей инсталляции ничего не меняет, только добавляет отсутствующее); поведение приложения после обновления не меняется, пока пользователь сам не поменяет значение в UI.lib/problemIssues.js (aging_wip_ratio_threshold — тот же движок, что у виджета «Требует внимания» и фильтра «Проблема» на Задачах), routes/teams.js (все три health-сигнала + их пороги для "риск"/"перегруз" + workload_normal_max/workload_at_limit_max для «Загрузки людей» — используется и экраном «Команды», и Отчётами, которые переиспользуют computeTeamsReport()), routes/sprints.js (review_stuck_days_threshold для риска «висит на ревью»). Все четыре места вызывают getThresholds() один раз в начале своей функции и передают дальше как обычные переменные — ни одного из этих чисел в коде больше не осталось захардкоженным.health_risk_signal_count/health_overload_signal_count: раньше это была жёстко зашитая лесенка 0 сигналов → норма, 1 → риск, 2+ → перегруз; теперь health = triggeredCount >= overloadCount ? 'overload' : triggeredCount >= riskCount ? 'risk' : 'normal' — общий случай, корректный при любых значениях overloadCount >= riskCount >= 1 (порядок дефолтов сохраняет прежнее поведение один-в-один).| Переменная | Назначение |
|---|---|
DATABASE_URL | строка подключения к Postgres (например, Neon) — уже подключена |
JIRA_CLIENT_ID | Client ID OAuth 2.0 (3LO) приложения в Atlassian Developer Console |
JIRA_CLIENT_SECRET | Client Secret того же приложения |
JIRA_CALLBACK_URL | URL обратного вызова, должен точно совпадать со значением в настройках приложения, например https://ai-delivery-copilot-backend.vercel.app/api/auth/callback |
FRONTEND_URL (опционально) | куда /api/auth/callback редиректит после успешной авторизации (добавляется ?jira=connected); по умолчанию https://ai-delivery-copilot.vercel.app — задайте, если фронтенд развёрнут на другом домене или для локальной разработки |
В приложении Jira нужно включить OAuth 2.0 (3LO) и выдать API-scopes read:jira-work, read:jira-user, offline_access (последний обязателен для получения refresh_token).
null (Team — как labels). Значения из Jira извлекаются универсальным хелпером extractFieldValue в routes/jira.js, который умеет доставать имя/value из объекта, брать последний элемент массива (актуально для Sprint — Jira отдаёт историю всех спринтов issue) или число.GET /rest/api/3/issue/{key}/changelog — точную структуру (курсорная или офсетная пагинация, ключ values или histories) не удалось верифицировать против реальной Jira: официальная документация Atlassian отдаёт JS-рендерящуюся SPA, не читаемую через обычный fetch, а сторонние источники (форумы, блоги) расходятся между собой. fetchChangelog написан защитно — поддерживает оба варианта — но стоит перепроверить на первом реальном sync с продовым Jira-сайтом.StatusBadge.jsx) сравнивается по statusCategory.key (new/indeterminate/done) — единственное локале-независимое значение; .name локализуется под пользователя так же, как и имя конкретного статуса (обнаружено и исправлено на реальном русскоязычном сайте — было первопричиной бага с cycle_time = 0).updated в Jira не изменился с прошлого sync, и оптимизация «не трогать changelog, если ничего не менялось» отдаёт закешированное значение из БД. Пересинкуйте с POST /api/jira/sync?force=1, чтобы пересчитать все issues независимо от updated.CREATE TABLE IF NOT EXISTS / ALTER TABLE ... ADD COLUMN IF NOT EXISTS в db.js) — отдельного шага миграции не требуется.cloud_id, без пользовательских аккаунтов) — это согласуется с тем, что и Jira-подключение в текущей реализации одно на всё приложение (одна строка в ).lead_time_days = resolutiondate − createdcycle_time_daysnullreopen_countGET /rest/api/3/issue/{key}/changelognextPageTokenisLaststartAttotalfetchChangelogGET /rest/api/3/statusfromString/toString — это дефолтные (обычно английские) имена статусов независимо от локали аккаунта, тогда как GET /rest/api/3/status (и текущий статус issue из search API) возвращает имена, локализованные под пользователя, выполняющего запрос. На нелокализованном (en) сайте оба совпадают и баг не проявляется; на любом другом — сопоставление по имени тихо не находит категорию ни для одного статуса, и cycle_time получается 0 для всех резолвнутых issues при корректном lead_time. Оба API также отдают числовой id статуса, который не зависит от локали и совпадает в обоих ответах — statusCategoryByName в fetchStatusCategoryMap строит карты byId (основная) и byName (фолбэк на случай записи changelog без id), categoryOf в computeLeadCycleReopen матчит сначала по id.DEBUG_CYCLE_TIME=1 в окружении backend, /api/jira/sync пишет в лог по каждой обработанной issue созданные сегменты статус-таймлайна (статус, id, категория, границы периода, кол-во дней, засчитан ли в cycle time) и итоговые cycleTimeDays/leadTimeDays/reopenCount — полезно для проверки на реальном Jira-сайте, если расчёт снова покажется подозрительным.updated изменилось с прошлой синхронизации (или для новых) — для остальных cycle_time/lead_time_days/reopen_count просто переносятся из уже сохранённой строки без лишнего похода в Jira.sync_progress (одна строка, id = 1) обновляется после каждой обработанной issue; GET /api/jira/sync/progress её читает. Это намеренно через БД, а не in-memory состояние на /sync — на Vercel запрос, который поллит прогресс, может попасть на другой инстанс serverless-функции, чем тот, что выполняет сам sync. Фронтенд (useSyncProgress.js) поллит каждые 800 мс, пока идёт синхронизация, и кнопки в JiraPanel.jsx и Dashboard.jsx показывают «Синхронизация... получено N из M задач» вместо простого текста.Статус — GET /api/jira/status возвращает { connected, issueCount, lastSyncedAt }.
Данные для дашборда — GET /api/jira/issues отдаёт содержимое таблицы issues, агрегированное для Dashboard.jsx (total/byStatus/byTeam/byType/issues), плюс lastSyncedAt. App.jsx подгружает эти данные автоматически при каждой загрузке страницы, если Jira подключена — если в БД уже есть данные, дашборд открывается сразу, без промежуточного экрана; если данных ещё нет (первое подключение), показывается приглашение синхронизироваться. Кнопка «Обновить данные из Jira» в шапке дашборда вызывает POST /api/jira/sync → GET /api/jira/issues, подставляет новые данные без перезагрузки страницы и обновляет отметку времени синхронизации; во время запроса кнопка блокируется и показывает прогресс. Рядом — «Полная пересинхронизация» (?force=1): пересчитывает lead_time_days/cycle_time/reopen_count для всех задач заново, даже если updated в Jira не менялся — нужно после изменения логики расчёта на бэкенде, иначе уже засинканные задачи бессрочно отдают старые сохранённые значения.
stats.issues, ничего не запрашивают у бэкенда; при активном фильтре карточки и разбивки «По статусу/команде/типу» пересчитываются на лету (computeStats в Dashboard.jsx) вместо агрегатов, присланных GET /api/jira/issues. GET /api/jira/issues для этого дополнительно отдаёт project/priority/assignee в каждой issue. Открытие виджета на весь экран (WidgetDrilldown.jsx, см. ниже) наследует уже применённый на дашборде фильтр и добавляет к нему свои локальные.App.jsx и переданном в Dashboard/Tasks пропсами, плюс сохраняется в localStorage (delivery-board:filters) на каждое изменение, так что значения переживают переключение вкладок (компоненты размонтируются при смене таба) и перезагрузку страницы. Строка поиска по ключу/названию на «Задачах» — не часть этого состояния, остаётся локальной. В «Задачах» те же фильтры теперь бьют в бэкенд как query-параметры GET /api/jira/tasks (project, periodDays — новые, status/team/type/priority были и раньше); periodDays фильтрует по created_at >= now() - N дней, аналогично клиентскому «Периоду» на Дашборде. Клик по строке/числу в развёрнутом виджете дашборда (onNavigateToTasks) заменяет общий фильтр целиком кликнутым измерением и переключает на «Задачи» — раньше это шло через одноразовый initialFilters-проп, теперь просто пишет в то же общее состояние.Маппинг полей — id кастомных полей (customfield_XXXXX) уникальны для каждого Jira-сайта, поэтому Sprint/Team/Story Points не хардкодятся, а настраиваются через UI:
GET /api/jira/fields — список всех полей текущего Jira-сайта (id + name) через GET /rest/api/3/field.GET /api/jira/field-mapping / POST /api/jira/field-mapping — чтение и сохранение соответствия canonical_field → jira_field_id в таблице jira_field_mapping (ключ — cloud_id, так что маппинг привязан к конкретному Jira-сайту)./api/jira/fields, кнопка «Сохранить маппинг». Открывается автоматически сразу после первого подключения Jira (если маппинг ещё пустой) и в любой момент повторно — кнопкой «Настройки полей» в JiraPanel.jsx.POST /api/jira/sync не падает и не блокируется — просто использует null для несопоставленных canonical-полей (для team дополнительно есть фолбэк на labels, как и раньше).Раздел «Задачи» (Tasks.jsx) — таблица issues из БД с живым локальным поиском (debounce 300 мс) и общим с Дашбордом фильтром (FilterBar.jsx, см. пункт 4a выше), пагинацией по 20 и экспортом текущей выборки в CSV. Backing endpoint GET /api/jira/tasks (query: search, повторяемые status/team/type/priority/project, periodDays, page; с export=csv отдаёт файл без пагинации) и GET /api/jira/tasks/filters (списки значений для дропдаунов, включая projects, — только то, что реально есть в БД). Колонка «Дней в статусе» считается на бэкенде от updated_at до текущего момента и показывает —, если status_category уже Done. Клик по строке открывает боковую панель со всеми полями задачи (TaskDetailPanel.jsx) и ссылкой «Открыть в Jira» — {site_url}/browse/{issue_key}, где site_url (например, https://your-domain.atlassian.net) сохраняется в jira_tokens при OAuth (Jira отдаёт его в том же ответе accessible-resources, откуда берётся cloud_id).
computeLeadCycleReopen в routes/jira.js) — тем же проходом по сегментам статусов, без отдельного запроса к Jira, и сохраняется в issues.status_time_breakdown (JSONB, [{status, days}] в хронологическом порядке первого попадания в статус) при каждом синке, где changelog реально перезапрашивался (при пропуске — как и cycle_time/lead_time_days — переносится из уже сохранённого значения). В отличие от cycle_time/lead_time_days, считается и для нерезолвленных задач. null (ещё не посчитано — changelog не запрашивался или задача синкана до появления этой колонки) отличается от пустого массива: панель в этом случае блок не показывает вовсе, а не рисует пустой/битый список.Верхняя навигация (TopNav.jsx) — табы «Дашборд», «Задачи», «Настройки» рабочие; «Команды», «Спринты», «Отчёты» показаны как заглушки (неактивны, без обработчиков) — задел под будущие разделы по общему макету продукта. 7a. Раздел «Настройки» (Settings.jsx) — два блока:
site_url) и «Переподключить Jira» (снова ведёт на /api/auth/login); полей для ручного ввода URL/токена нет и не будет, т.к. вся авторизация идёт через OAuth. Там же — «Настройки полей» (открывает существующий FieldMapping.jsx).sync_history (Время/Источник/Задач/Итог, с текстом ошибки в title при наведении на «ошибка») и кнопка «Обновить сейчас» (тот же syncJira(), с тем же live-прогрессом через useSyncProgress, что и кнопки на дашборде).SYNC_JQL в routes/jira.js, с единственным техническим ограничением updated >= -3650d, нужным лишь потому что /search/jql отклоняет полностью неограниченный JQL). Фильтрация — это фильтры на «Дашборде» и в «Задачах» (Tasks.jsx, MultiSelectFilter.jsx), которые работают клиентски/по уже загруженным из БД данным, а не управляют тем, что попадает в БД при sync.Разворот виджетов дашборда (WidgetDrilldown.jsx) — карточки «По статусу», «По команде», «По типу» получили иконку ⛶, разворачивающую виджет на весь экран поверх обычного дашборда (не роут, локальный expandedWidget state в Dashboard.jsx). Полноэкранный вид считает группировку прямо на клиенте из уже загруженного stats.issues — никаких новых полей в БД не потребовалось. Слева — локальные фильтры (Период/Команда/Тип задачи), которые сужают только это представление и не трогают stats в Dashboard. «По команде» — единственная группировка с реальной вложенностью (команда → статус, разворачивается стрелкой, если у команды больше одного статуса); «По статусу» и «По типу» — плоские таблицы. Клик по названию группы (или по числу в строке без вложенности) переключает верхнюю навигацию на «Задачи» с фильтром по этому одному измерению; клик по числу в развёрнутой подстроке — с комбинированным фильтром (например, команда + статус одновременно). Передача фильтра между экранами — через navigateToTasks в App.jsx, который пишет кликнутое измерение в общее состояние фильтров (useSharedFilters.js, см. пункт 4a) и переключает вкладку на «Задачи».
Спринты (Jira Agile API, только бэкенд) — POST /api/jira/sync дополнительно тянет данные о спринтах через /rest/agile/1.0/*, отдельным best-effort шагом после основной синхронизации issues (syncSprintsForCloud в routes/jira.js):
GET /rest/agile/1.0/board?projectKeyOrId={project} (доски этого проекта; пагинация startAt/maxResults/isLast, как и у остальных Agile-эндпоинтов — в отличие от курсорной пагинации /rest/api/3/search/jql).GET /rest/agile/1.0/board/{boardId}/sprint?state=active,closed. Kanban-доска без спринтов отвечает 400 — это нормальный случай, не ошибка синка (fetchSprintsForBoard в этом случае просто возвращает []).CLOSED_SPRINTS_TO_KEEP = 3 завершённых по каждой доске (selectSprintsToTrack), чтобы не тянуть всю историю. Апсертятся в таблицу sprints (jira_sprint_id UNIQUE).GET /rest/agile/1.0/sprint/{sprintId}/issue?fields=created (только key+created, больше для этой задачи не нужно). Для каждой найденной там issue, если она уже есть в нашей таблице issues (иначе пропускается — например, задача была отфильтрована SYNC_JQL или относится к другому проекту), апсертится строка в issue_sprints (issue_id, sprint_id, added_after_sprint_start).issue_sprints (issue может быть в нескольких спринтах за свою жизнь), а не колонка на issues; существующая текстовая issues.sprint (имя последнего спринта из маппинга полей) не тронута и продолжает работать как раньше.added_after_sprint_start — простое сравнение issue.created_at > sprint.start_date (не по changelog поля Sprint — это было бы точнее, но означало бы ещё один запрос к Jira на issue сверх того, что и так уже делает основной синк)./sync — best-effort, обёрнуто в свой try/catch; итоговая ошибка (если есть) попадает в sprintSyncError в ответе /sync и в колонку error строки sync_history. sync_history дополнительно получила колонки sprints_synced/sprint_issue_links, отдаются и в GET /api/jira/sync/history.⚠️ В процессе донастройки на реальном Jira-сайте: Agile-эндпоинты требуют granular-скоупы, по одному на ресурс, а не один общий — классических read:jira-work/read:jira-user (достаточных для обычного /rest/api/3/*) не хватает ни для одного из них. Найдено методом «чиним 401 scope does not match по очереди»: /rest/agile/1.0/board заработал после read:board-scope:jira-software + read:project:jira, /board/{id}/sprint — после read:sprint:jira-software. GET /sprint/{id}/issue (issues спринта) с той же ошибкой оказался упрямее: read:issue-details:jira (не -jira-software — такого granular-скоупа не существует в Developer Console) сам по себе не хватило; по форумному треду Atlassian с точно такой же ошибкой на этом же эндпоинте туда же нужен read:jql:jira (эндпоинт под капотом устроен как JQL-поиск). Текущий scope в routes/auth.js: read:jira-work read:jira-user offline_access read:board-scope:jira-software read:project:jira read:sprint:jira-software read:issue-details:jira read:jql:jira — пока не подтверждено на реальном сайте, следующая проверка после переподключения Jira покажет, хватило ли этого набора. Смешивание classic- и granular-скоупов в одном запросе авторизации это конкретное приложение принимает нормально. Ошибка не проходит незамеченной: первая по любому проекту/доске/спринту (firstError в syncSprintsForCloud) попадает в sprintSyncError в ответе /sync и в error соответствующей строки sync_history — видна в колонке «Спринтов» истории синхронизаций в «Настройках» (SyncHistory.jsx, которая показывает и число синканных спринтов, и число связей issue↔спринт отдельно, даже когда сам синк issues прошёл успешно).
Раздел «Команды» (Teams.jsx, backend в routes/teams.js, смонтирован как /api/teams) — целиком читает уже засинканные данные, живых запросов к Jira не делает.
useState в Teams.jsx, в localStorage не пишется. «Спринт» фильтрует по внутреннему sprints.id (не по Jira jira_sprint_id).issuelinks добавлено в список полей issue-запроса (BASE_FIELDS в routes/jira.js), приходит бесплатно с обычным синком issues, отдельных запросов в Jira не требует. На каждый синк для issue таблица issue_links полностью перестраивается (DELETE + вставка из текущего issuelinks). Каждая связь хранится с точки зрения issue, из которой её видно — link_type это конкретная фраза направления («is blocked by» / «blocks»), а не просто имя типа связи, чтобы блокер определялся простым ILIKE '%blocked by%' без знания всех возможных типов связей сайта. «Блокер» = issue, у которой есть такая связь на issue с status_category != done.GET /api/jira/team-roles), сгруппирован по командам; у каждого участника dropdown с ролью (Аналитик/Разработчик/QA/Дизайнер/своя через «Другое...»), сохраняется сразу (POST /api/jira/team-roles, свободный текст — с debounce 600 мс) в таблицу team_roles (UNIQUE (team, assignee_name)). Без роли участник помечается «роль не задана» и не учитывается в сумме WIP-лимита команды (см. ниже).indeterminate-статусу пары (роль → лимит «на человека этой роли»), роли предлагаются из уже назначенных в team_roles этой команды, «+ добавить роль» — вторая пара роль/лимит на тот же статус. «Сохранить» разом заменяет весь конфиг команды (POST /api/jira/wip-limits, body { team, entries }) в таблице wip_limits (UNIQUE (team, status_name, role)) — в отличие от остальных настроек, здесь уместна явная кнопка «Сохранить», это разовое действие в модалке, а не постоянно видимая форма. Общий лимит команды (используется в таблице «Команды» и в сигнале «WIP» ниже) = Σ limit_value × (число участников команды с этой ролью в team_roles) по всем её строкам wip_limits — не плоский лимит на статус, как было в первой версии этой фичи (полностью удалена вместе со старой глобальной таблицей wip_limits; при апгрейде с той версии db.js один раз дропает старую схему таблицы — см. комментарий в ensureSchema).indeterminate-статусах / общий лимит команды с прогресс-баром — если у команды нет ни одной строки в wip_limits, или ни одному участнику не назначена роль, показывается только число WIP без дроби), Velocity SP (сумма story points всех задач выбранного спринта — или, если фильтр «Спринт» не выбран, последнего спринта команды: активного, иначе последнего закрытого), Cycle time (среднее по завершённым задачам команды), Баги % (доля issue_type = Bug), Блокеры (см. выше), Здоровье.resolveLastSprint/health-блок в routes/teams.js): (1) текущий WIP / лимит команды (см. выше; сигнал пропускается, если лимит не настроен), срабатывает при > 1.0; (2) средний cycle time завершённых задач за последние 30 дней / средний cycle time по всем завершённым задачам команды, срабатывает при > 1.3; (3) SP завершённых задач последнего закрытого спринта / средняя по предыдущим (до 5) закрытым спринтам, срабатывает при < 0.7. Итог — «норма» (0 сработавших), «риск» (1), «перегруз» (2+). Если у команды меньше 3 завершённых задач за всё время или нет ни одного закрытого спринта — «недостаточно данных», сигналы вообще не считаются.issue_type за выбранный период (весь спринт, если активен фильтр «Спринт», иначе всё время команды) — типы берутся из реальных данных, не захардкожены.added_after_sprint_start != true), Сделано (SP завершённых), Перенесено (число незавершённых на конец), Scope creep % (доля added_after_sprint_start = true).Раздел «Спринты» (Sprints.jsx, backend в routes/sprints.js, смонтирован как /api/sprints) — burndown, cumulative flow, история спринтов и риски активного спринта, целиком из уже засинканных данных.
issue_status_events — новая таблица, ключевая для этого раздела: одна строка на каждую смену категории статуса issue (плюс синтетическая «genesis»-запись в created_at с исходной категорией) — {issue_id, changed_at, status, status_category}. Строится тем же changelog-replay проходом, что и cycle_time/status_time_breakdown (computeLeadCycleReopen в routes/jira.js, теперь дополнительно возвращает statusTimeline), перестраивается (DELETE+вставка) только когда синк реально перезапрашивал changelog для issue — как и issue_links. Это то, что позволяет восстановить «в какой категории была задача на день D» без повторного похода в Jira: берётся последняя запись с changed_at <= D.added_after_sprint_start, + число задач), Сделано (SP завершённых + % с прогресс-баром), Прогноз завершения (только для активного спринта: (Сделано / дней с начала) × общая длительность / Взято, обрезано до [0,100]; для закрытого спринта карточка заменяется на «Итог» с фактическим %), Добавлено после старта (SP + scope creep % — доля issues с added_after_sprint_start = true), Риски (число + через «+» какие категории из блока ниже встретились).start_date до end_date (реальные даты из Jira Agile API, не предположение о стандартной длине — настройки «длины спринта» поэтому нет и не нужно: для только что стартовавшего спринта ось X сразу показывает все дни до конца, а не только 1–2 прошедших). Линия «факт» — сумма SP issues, чья категория на конец дня (через issue_status_events) ещё не done, определена только по дни min(today, end_date) включительно (для будущих дней actualSp = null, точка на графике не рисуется вместо падения в 0) — линия «идеал» определена на весь диапазон сразу. Идеальная линия — прямая от текущего общего SP спринта (то же число, что в KPI «Взято») до 0 на end_date. «Отставание N SP» = факт − идеал на последний день с реальным фактом, показывается только когда положительное и только для активного спринта. Поток по статусам (CFD), в отличие от burndown, дальше min(today, end_date) не продлевается — будущее состояние статусов неизвестно по определению; в легенде под графиком показаны актуальные (на последний день) количества по каждой категории.issue_status_events, но количество issues (не SP) по трём категориям done/indeterminate/new. Issue, ещё не существовавшая на день D (создана позже), в этот день нигде не учитывается — упрощение: точная дата добавления в спринт не хранится (только флаг added_after_sprint_start), поэтому used proxy — дата создания задачи.MAX_HISTORY_SPRINTS = 5), но соблюдает Команда/Тип задачи фильтры. Колонки — Взято/Сделано SP, Выполнение % (Сделано/Взято), Перенесено (issues не в done к текущему моменту/концу спринта), Scope creep %, Ср. cycle time (среднее cycle_time завершённых issues спринта). «Итог»: closed для закрытых; для активного — в риске/на треке через сравнение факта с ожидаемым по линейному темпу процентом на сегодняшний день спринта. Кнопка «Экспорт» формирует CSV на клиенте (данные уже загружены, отдельный backend-эндпоинт не нужен) — кнопка «Сравнить спринты» из макета не реализована.issue_links, см. пункт 10 выше — таблица уже была реализована в задаче «Команды», отдельно реализовывать не потребовалось), «висит на ревью» (текущий статус содержит «ревью»/review, регистронезависимо, и в нём дольше REVIEW_STALE_DAYS = 3 дней — временный фиксированный порог, второй вариант из ТЗ, «1.5× среднего по команде», не реализован, т.к. требует отдельного расчёта baseline'ов по командам, которого пока нигде нет), «без исполнителя» (assignee IS NULL и категория indeterminate). Категория «не успеет» не реализована (в ТЗ отмечена как ненадёжно вычислимая). Если issue подходит под несколько категорий сразу, показывается одна — по приоритету блокер → ревью → без исполнителя, чтобы не дублировать строки. Сортировка — блокеры первыми, затем по убыванию «Дней».Библиотека виджетов на Дашборде — без drag&drop и без конструктора метрик: виджет либо есть на дашборде целиком (заранее запрограммированный компонент), либо его нет.
dashboard_widgets (id, widget_type UNIQUE, position, enabled) — какие виджеты сейчас на дашборде и в каком порядке. При первом запуске (таблица реально пустая — не «все виджеты убраны», для этого отдельная проверка WHERE NOT EXISTS (SELECT 1 FROM dashboard_widgets)) сеется 4 виджета по умолчанию: stats_cards (все 4 карточки статистики — одним виджетом, не по отдельности), by_status, by_team, by_type. Пятый тип, attention, не сеется по умолчанию. Удаление — не DELETE, а enabled = false, поэтому повторное добавление того же виджета восстанавливает его на старой позиции, а не в конец.GET /api/dashboard/widgets / POST /api/dashboard/widgets/:type (body { enabled }) в routes/dashboard.js — список читает и «Библиотека виджетов» (WidgetLibraryModal.jsx: без категорий/поиска/настройки ширины — при пяти виджетах это не нужно), и сам Dashboard (только enabled: true, по position). У каждого виджета на дашборде — меню «···» (WidgetMenu.jsx) с «Удалить виджет». Раскладка (3 узких by_*-виджета в один ряд, stats_cards/attention каждый на всю ширину) — фиксированные метаданные по типу виджета в Dashboard.jsx (FULL_WIDTH_TYPES), не настройка конкретного экземпляра.issue_links (таблица уже существовала — см. пункт 10, отдельно реализовывать не пришлось).indeterminate) задача, у которой (сегодня − started_at) / эталон > 0.9. Эталон — среднее cycle_time завершённых задач той же команды с ТЕМ ЖЕ story_points (если таких ≥ 3), иначе 85-й перцентиль cycle_time по всем завершённым задачам команды (nearest-rank, без интерполяции — в ТЗ не оговорён метод). Задача без story_points или без истории (< 3 в своей SP-группе) тоже уходит на перцентильный fallback. Без эталона вообще (у команды нет ни одной завершённой задачи) — задача не классифицируется никак, не «зависла» по умолчанию.aging_ratio).GET /api/dashboard/attention учитывает текущий общий фильтр Дашборда (Проект/Команда/Тип/Статус/Приоритет/Период) — точно так же, как остальные виджеты; превью — 10 строк, при большем числе — «N задач всего» и переход в «Задачи» с уже применённым фильтром «Проблема» = обе категории сразу (onNavigateToTasks({ problem: [...] }), тот же механизм полной замены общих фильтров, что и у клика по виджетам По статусу/По команде/По типу). Клик по строке открывает ту же боковую панель (TaskDetailPanel.jsx), что и в «Задачах» — эндпоинт отдаёт задачи в том же формате, что и GET /api/jira/tasks, так что клик не требует отдельного похода за деталями.showProblem, которым Dashboard.jsx не пользуется. GET /api/jira/tasks резолвит значения «Блокер»/«Зависла» в конкретный список id через computeProblemIssues() и подмешивает id = ANY($n::int[]) к остальным условиям buildTaskFilters — то есть фильтр «Проблема» комбинируется с любыми другими фильтрами Задач как обычное условие, не отдельный режим.WidgetFilterPopover.jsx) — переопределяют часть общих фильтров дашборда для ОДНОГО виджета, не трогая остальные. Реализовано осознанно ограниченно относительно макета («период/команда/тип и т.д.»): По статусу/По команде/По типу/Требует внимания/Задачи по командам — только локальный «Период»; Throughput/Burndown — локальные Команда/Тип (Период для них не имеет смысла — см. throughput выше, а спринт не находится «внутри» периода). Полноценный локальный набор Проект/Команда/Тип/Статус/Приоритет на каждый виджет не реализован — при необходимости легко расширяется по той же схеме.WidgetDrilldown.jsx (group-by с суб-разбивкой). Требует внимания/Задачи по командам/Throughput/Burndown получили общую модалку-оверлей в Dashboard.jsx (expandedGeneric), рендерящую тот же компонент виджета с fullScreen — не переиспользует WidgetDrilldown, так как эти виджеты не таблица «группировка по измерению задач», а собственная агрегация.grid-cols-2) вместо прежней трёхколоночной; понятие «full-width виджет» убрано целиком, включая «Требует внимания» — теперь он обычная ячейка сетки, как в макете 1b.backend/lib/period.jsfilters.period'custom'filters.periodStartfilters.periodEndresolvePeriodRange(query)jira.jsteams.jsdashboard.jsreports.jsextractIssueLinks/mapJiraFields в routes/jira.js) вообще нет ни epic id, ни epic name — поле эпика не запрашивается и не сохраняется нигде. Это не «только ID без названий» (тогда решение было бы очевиднее), а полное отсутствие данных — ровно тот случай, когда задание просило остановиться и спросить, а не додумывать отображение. Ждёт ответа пользователя.Settings.jsx) — сайдбар-навигация (Подключение Jira / Команды и люди / Метрики и SLA / Алерты / История синхронизаций), справа только выбранный раздел. Существующие блоки просто разложены по разделам без изменения их внутренней логики (TeamRoles, WipLimitsModal, SyncHistory уже были самодостаточными карточками). «Алерты» — заглушка с текстом про блокеры/отставание, функциональность сознательно не реализована (требует email/Slack-инфраструктуры).jira_tokens