factcircuit
FactCircuit: an auditable claim-and-evidence verification loop with immutable provenance, staged checks, bounded retrieval, and reproducible evaluation.
Delivery Intelligence Dashboard
Translation pending
DataJavaScript · HTMLGitHub @m76600350-bitOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
Дашборд для анализа задач, синхронизированных из 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 соRead the full 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