sdlc-hub/src/jira_hub/web/links.html
Andrey Epifancev c35151b841 chore(web): страница «Спринт 2 линк» как есть — основа доски хаба (#17)
Копия admin/sprint-2-links.html от 11.10.2026 без правок, чтобы дальнейшие изменения читались диффом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:55:10 +04:00

1059 lines
115 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<meta charset="utf-8">
<title>Спринт 2 линк</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500&display=swap">
<style>
/* Страница «Спринт 2 линк»: кто кого блокирует в спринте. Карточки — задачи Jira, стрелки — связи, дорожки — эпики.
Разделы стилей: 1 цвета и темы · 2 основа · 3 закреплённая шапка · 4 кнопки и выпадающие списки · 5 строка управления, легенда,
предупреждения · 6 доска: дорожки и карточки · 7 панель проблем · 8 экспорт и таблица связей · 9 узкие экраны */
/* 1. Цвета и темы: светлая по умолчанию, тёмная по системе или по выбору в меню */
:root{
--bg:#f3f4f1; --fg:#1f2321; --muted:#6b7069; --line:#d6d8d0; --card:#ffffff; --panel:#ffffff; --soft:#eaece6;
--accent:#1f6f8b; --accent-fg:#ffffff; --edge:#9a9f97; --edge-strong:#1f6f8b; --guide:#c2410c;
--k-decision:#b4542a; --k-analytics:#8a6d1f; --k-dev:#1f6f8b; --k-ui:#5b4b9a; --k-test:#2f7d4f; --k-org:#6b7069;
--p-crit:#b23a3a; --p-high:#c7772a; --p-mid:#6b7069; --p-low:#9a9f97;
--font-body:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
--font-mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
}
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }}
:root[data-theme="dark"]{
--bg:#151817; --fg:#e8e6df; --muted:#9a9f97; --line:#303532; --card:#1f2422; --panel:#1b201e; --soft:#252a28;
--accent:#6fb3cc; --accent-fg:#101413; --edge:#6c726b; --edge-strong:#6fb3cc; --guide:#f08a5a;
--k-decision:#e08a5a; --k-analytics:#d4b35a; --k-dev:#6fb3cc; --k-ui:#a493d8; --k-test:#6cc08f; --k-org:#9a9f97;
--p-crit:#e36b6b; --p-high:#e3a059; --p-mid:#9a9f97; --p-low:#6c726b; color-scheme:dark }
/* 2. Основа */
html,body{min-height:100%}
body{background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:15px;line-height:1.45;padding-inline:16px;padding-block:0 32px;margin:0}
h1{font-size:20px;font-weight:600;margin:0}
[hidden]{display:none!important}
.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.spacer{flex:1 1 auto}
.hint{font-size:13px;color:var(--muted)}
.warn{color:var(--p-high)}
/* 3. Закреплённая шапка: название, файл, статистика, кнопка проблем, меню; под ней строка управления и легенда */
.sticky{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:var(--bg);padding-block:12px 8px;margin:0 0 8px;box-shadow:0 1px 0 var(--line)}
.top{display:flex;align-items:center;gap:6px 14px;flex-wrap:wrap;margin:0 0 8px;min-height:34px}
.src{display:inline-flex;align-items:baseline;gap:8px;min-width:0}
.file-name{font-size:14px;color:var(--muted);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.link{font:inherit;font-size:14px;color:var(--accent);cursor:pointer;background:none;border:0;padding:0;text-decoration:underline dotted;white-space:nowrap}
.link:hover,.link:focus-visible{text-decoration-style:solid;outline:none}
.stats{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.stats b{color:var(--fg);font-weight:500}
/* 4. Кнопки и выпадающие списки */
.btn{font:inherit;font-size:14px;line-height:1.2;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:0 12px;cursor:pointer;height:34px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;gap:5px}
.btn:hover,.btn:focus-visible{border-color:var(--accent);outline:none}
.btn[aria-pressed="true"]{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
.btn .n{font-weight:600;color:var(--p-crit)}
.btn .n.zero{font-weight:500;color:var(--muted)}
.btn[aria-pressed="true"] .n{color:inherit}
.btn.sm{height:28px;padding:0 8px;font-size:13px}
.more-btn{padding:0 10px;font-size:20px;line-height:1;letter-spacing:.05em}
.dd{position:relative;display:inline-block}
.dd-btn{max-width:360px;gap:8px;justify-content:space-between}
.dd-btn .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dd-btn .car{font-size:10px;opacity:.7}
.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 10px 28px rgba(0,0,0,.18);padding:8px;display:flex;flex-direction:column;gap:6px}
.dd-menu input[type=search]{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box}
.dd-menu input[type=search]:focus-visible{border-color:var(--accent);outline:none}
.dd-list{max-height:260px;overflow:auto;display:flex;flex-direction:column}
.dd-item{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:5px;cursor:pointer;font-size:14px}
.dd-item:hover{background:var(--soft)}
.dd-item input{accent-color:var(--accent);margin:0;width:14px;height:14px;flex:0 0 auto}
.dd-item .n{margin-left:auto;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
.dd-item.none{color:var(--muted);font-style:italic}
.dd-item.zero{opacity:.5}
.dd-foot{display:flex;gap:6px;justify-content:space-between;border-top:1px solid var(--line);padding-top:6px}
.dd-foot .btn{height:30px;padding:0 10px;font-size:13.5px}
.dd-empty{font-size:12.5px;color:var(--muted);padding:6px}
.dd-sep{border:0;border-top:1px solid var(--line);margin:4px 0}
/* меню «⋯»: открывается к левому краю, пункты с галочкой — переключатели */
.dd-menu.menu{right:0;left:auto;min-width:230px;padding:6px}
.menu button{font:inherit;font-size:14px;text-align:left;background:none;border:0;border-radius:5px;padding:7px 10px;color:var(--fg);cursor:pointer;width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px}
.menu button:hover,.menu button:focus-visible{background:var(--soft);outline:none}
.menu button[aria-pressed="true"]::after{content:"✓";color:var(--accent);font-weight:600}
.menu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
.menu .danger{color:var(--p-crit)}
.confirm{display:flex;flex-direction:column;gap:6px;padding:6px 10px;font-size:14px}
.confirm .row{flex-wrap:nowrap}
.confirm .btn{height:28px;padding:0 10px;width:auto;flex:1 1 0}
.confirm .btn.yes{background:var(--p-crit);color:#fff;border-color:var(--p-crit)}
/* 5. Строка управления, предупреждения о файле, легенда-фильтр */
.bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 8px}
.notice{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:13.5px;color:var(--p-high);margin:0 0 6px}
.legend{display:flex;flex-wrap:wrap;align-items:center;gap:2px 12px;margin:0;font-size:13px;color:var(--muted)}
.legend span{display:inline-flex;align-items:center;gap:6px}
.legend .sep{width:1px;height:14px;background:var(--line)}
.legend .hint{margin-left:auto}
.lg{font:inherit;font-size:13px;color:var(--muted);background:none;border:0;padding:2px 5px;border-radius:4px;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
.lg:hover,.lg:focus-visible{background:var(--soft);color:var(--fg);outline:none}
.lg.off{opacity:.45;text-decoration:line-through}
.sw{width:10px;height:10px;border-radius:2px;display:inline-block}
.ln{width:26px;height:0;border-top:2px solid var(--edge-strong);display:inline-block}
.ln.dash{border-top-style:dashed;border-top-color:var(--edge)}
.ln.hand{border-top-color:var(--guide)}
.ln.crit{border-top-width:4px;border-top-color:var(--guide)}
.out-sw{width:16px;height:11px;border:1.5px dashed var(--muted);border-radius:3px;display:inline-block}
/* 6. Доска: прокрутка только по горизонтали (вертикальная — у страницы), дорожки по эпикам, карточки абсолютные */
.stage{display:flex;gap:10px;align-items:stretch}
.wrap{flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;border:1px solid var(--line);border-radius:8px;background:var(--card);user-select:none;-webkit-user-select:none}
.board{position:relative;min-width:max(100%,max-content)}
svg.edges,svg.guides{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
svg.guides{z-index:3}
svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
.empty{padding:40px 16px;color:var(--muted);text-align:center}
.lane{position:absolute;left:0;right:0;border-top:1px solid var(--line)}
.lane:nth-child(even of .lane){background:color-mix(in srgb,var(--soft) 45%,transparent)}
.lane.folded{background:color-mix(in srgb,var(--soft) 80%,transparent)}
.lane.target{box-shadow:inset 0 3px 0 var(--accent)}
.lane.target-after{box-shadow:inset 0 -3px 0 var(--accent)}
.lane.dragging{opacity:.6}
.lane-head{position:absolute;left:8px;top:8px;display:flex;align-items:center;gap:4px;z-index:1}
.fold{width:18px;height:18px;border:0;background:none;color:var(--muted);cursor:pointer;padding:0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:0}
.fold::before{content:"";border:5px solid transparent;border-top:6px solid currentColor;border-bottom:0;transition:transform .15s}
.fold:hover{background:var(--soft);color:var(--fg)}
.lane.folded .fold::before{transform:rotate(-90deg)}
.lane-name{font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;cursor:grab;padding:2px 4px;border-radius:4px;user-select:none;touch-action:none}
.lane-name:hover{background:var(--soft);color:var(--fg)}
.lane-count{display:none;font-size:12.5px;color:var(--muted);margin-left:4px}
.lane.folded .lane-count{display:inline}
.lane-tools{display:none;gap:2px}
.lane:hover .lane-tools{display:inline-flex}
.lane-tools button{font:inherit;font-size:12px;line-height:1;color:var(--muted);background:var(--card);border:1px solid var(--line);border-radius:4px;width:22px;height:22px;cursor:pointer;padding:0}
.lane-tools button:hover{border-color:var(--accent);color:var(--accent)}
.lane-tools button:disabled{opacity:.35;cursor:default}
/* режим «по датам»: колонки по плановым датам окончания, шапка колонки над дорожками; карточки двигаются внутри своей колонки */
.dcol{position:absolute;top:0;border-left:1px solid var(--line);box-sizing:border-box;pointer-events:none}
.dcol:first-of-type{border-left:0}
.dcol.past{background:color-mix(in srgb,var(--p-crit) 5%,transparent)}
.dcol.today{background:color-mix(in srgb,var(--accent) 7%,transparent)}
/* полоса дат над доской: липнет под шапкой страницы, по горизонтали двигается вместе с доской (transform по scrollLeft) */
.dates-bar{position:sticky;top:calc(var(--head-h,0px) + env(safe-area-inset-top,0px));z-index:9;background:var(--bg)}
.dates-in{overflow:hidden;border:1px solid var(--line);border-bottom:0;border-radius:8px 8px 0 0;background:var(--card);height:36px;box-sizing:border-box}
.dates-track{position:relative;height:100%}
.dlabel{position:absolute;top:9px;font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--fg);font-weight:600;white-space:nowrap}
.dlabel .n{font-weight:400;color:var(--muted);margin-left:6px;font-variant-numeric:tabular-nums}
.dlabel.past{color:var(--p-crit)}
.dlabel.today{color:var(--accent)}
.dlabel.none{color:var(--muted);font-weight:500}
.stage.bydate .wrap{border-top-left-radius:0;border-top-right-radius:0}
.dsplit{position:absolute;top:0;bottom:0;width:11px;margin-left:-5px;cursor:col-resize;z-index:1}
.dsplit::after{content:"";position:absolute;left:5px;top:6px;bottom:6px;width:1px;background:var(--line)}
.dsplit:hover::after,.dsplit.on::after{background:var(--accent);width:3px;left:4px;border-radius:2px}
.dates-in.resizing{cursor:col-resize}
/* горизонтальная прокрутка доски, прилипшая к низу окна: полоса всегда видна, скроллбар нарисован явно (на macOS системный прячется) */
.hscroll{position:sticky;bottom:0;z-index:8;overflow-x:scroll;overflow-y:hidden;height:14px;background:var(--bg);padding-top:2px;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:var(--edge) var(--soft)}
.hscroll>div{height:1px}
.hscroll::-webkit-scrollbar{height:12px}
.hscroll::-webkit-scrollbar-track{background:var(--soft);border-radius:6px}
.hscroll::-webkit-scrollbar-thumb{background:var(--edge);border-radius:6px;border:2px solid var(--soft)}
.hscroll::-webkit-scrollbar-thumb:hover{background:var(--accent)}
/* карточка: текст выделяется, тянуть можно только за рамку (появляется при наведении) и за уголок */
.card{position:absolute;width:240px;height:120px;box-sizing:border-box;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;display:flex;flex-direction:column;gap:4px;overflow:hidden;cursor:default;touch-action:none}
.card:hover{border-color:var(--accent)}
.card.out{border-style:dashed;opacity:.7}
.card.crit{box-shadow:0 0 0 2px var(--guide)}
.card.focus{box-shadow:0 0 0 2px var(--accent)}
.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2}
.card .title,.card .state,.card .who,.card .meta,.card .key,.card .sprint-tag{user-select:text;-webkit-user-select:text;cursor:text;touch-action:auto}
.card .key{cursor:pointer}
.card .meta{cursor:default}
.card .grip{position:absolute;background:var(--accent);opacity:0;cursor:grab;z-index:1;transition:opacity .12s}
.card .grip.t{left:0;right:0;top:0;height:8px;border-radius:5px 5px 0 0}
.card .grip.b{left:0;right:14px;bottom:0;height:8px;border-radius:0 0 0 5px}
.card .grip.l{left:0;top:0;bottom:0;width:8px;border-radius:5px 0 0 5px}
.card .grip.r{right:0;top:0;bottom:14px;width:8px;border-radius:0 5px 0 0}
.card:hover .grip,.card.drag .grip{opacity:.85}
.card.drag .grip{cursor:grabbing}
.card .rsz{position:absolute;right:0;bottom:0;width:14px;height:14px;cursor:nwse-resize;background:linear-gradient(135deg,transparent 50%,var(--line) 50%);border-bottom-right-radius:5px;z-index:2}
.card:hover .rsz{background:linear-gradient(135deg,transparent 50%,var(--accent) 50%)}
.head{display:flex;align-items:center;justify-content:space-between;gap:6px;min-width:0}
.key{font-family:var(--font-mono);font-size:13.5px;font-weight:500;color:var(--accent);text-decoration:none;flex:0 0 auto}
.sprint-tag{font-size:11px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);color:var(--muted);max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto}
.sprint-tag.sel{background:var(--accent);color:var(--accent-fg)}
.sprint-tag.none{background:transparent;border:1px dashed var(--line)}
.title{font-size:14.5px;line-height:19px;min-width:0;flex:1 1 auto;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
.meta{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted);white-space:nowrap;flex:0 0 auto}
.kind{display:inline-flex;align-items:center;gap:4px}
.kind i{width:8px;height:8px;border-radius:2px;display:inline-block}
.prio{font-weight:600}
/* состояние задачи (статус, срок, исполнитель) — отдельный блок под чертой, основным цветом: это главное после названия */
.state,.who{font-size:13px;flex:0 0 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--fg)}
.state{display:flex;align-items:center;gap:6px;margin-top:4px;padding-top:7px;border-top:1px solid var(--line)}
.status{font-size:12.5px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.due{font-variant-numeric:tabular-nums;font-weight:600;flex:0 0 auto}
.who::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-right:5px;vertical-align:1px}
.who.none{color:var(--muted);font-style:italic}
.who.none::before{background:transparent;border:1px solid var(--muted)}
/* 7. Панель проблем справа от доски, липнет под шапкой и прокручивается внутри себя */
.drawer{flex:0 0 340px;max-width:40%;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;overflow:auto;font-size:14px;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px)}
.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
.drawer-head b{font-weight:600}
.drawer h4{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;margin:12px 0 4px}
.drawer ul{margin:0;padding-left:18px}
.drawer li{margin:3px 0}
.drawer .k{font-family:var(--font-mono);font-size:13px;color:var(--accent);text-decoration:none}
.drawer .ok{color:var(--k-test);margin:8px 0 0}
.drawer .why{color:var(--muted)}
/* 8. Экспорт JSON и таблица связей (включаются из меню «⋯») */
.export{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:0 0 8px;display:flex;flex-direction:column;gap:8px}
.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:8px;resize:vertical}
details{margin-top:12px}
summary{cursor:pointer;color:var(--muted);font-size:14px}
.tbl{max-width:100%;overflow-x:auto;margin-top:8px}
table{border-collapse:collapse;font-size:14px;min-width:520px}
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500}
td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap}
/* 9. Движение и узкие экраны */
@media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}}
@media (prefers-reduced-motion:reduce){.card .grip,.fold::before{transition:none}}
@media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}}
@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}.file-name{max-width:100%}}
</style>
<div class="sticky">
<div class="top">
<h1>Спринт 2 линк</h1>
<span class="src"><span class="file-name" id="fileName"></span><label class="link" for="file" title="CSV из фильтра Jira или JSON, выгруженный с этой страницы. Расстановка карточек, дорожки и фильтры сохраняются по ключам задач и после замены файла остаются">заменить…</label><input type="file" id="file" accept=".csv,.json,text/csv,application/json" hidden></span>
<span class="stats" id="stats"></span>
<span class="spacer"></span>
<button class="btn" id="probBtn" type="button" aria-pressed="false" title="Сроки блокеров, задачи без исполнителя, передачи между людьми">Проблемы <span class="n zero" id="probCount">0</span></button>
<div class="dd" id="more">
<button type="button" class="btn more-btn" aria-haspopup="menu" aria-expanded="false" title="Ещё">⋯</button>
<div class="dd-menu menu" hidden>
<button type="button" id="exportJson">Экспорт JSON</button>
<button type="button" id="tblToggle" aria-pressed="false">Таблица связей</button>
<button type="button" id="snap" aria-pressed="true">Привязка к сетке</button>
<button type="button" id="theme">Тема: системная</button>
<hr>
<span id="resetWrap"><button type="button" class="danger" id="reset">Сбросить раскладку</button></span>
</div>
</div>
</div>
<div class="bar">
<span id="sprint"></span><span id="who"></span>
<button class="btn" id="neighbors" type="button" aria-pressed="false" title="Добавить к отобранным задачам их соседей по связям из любого спринта, любого исполнителя и вида работы. Соседи рисуются пунктиром и считаются отдельно">+ соседи по связям</button>
<button class="btn" id="onlyHand" type="button" aria-pressed="false" title="Оставить только стрелки, где задачу ждёт другой человек. Карточки не убирает — для этого «скрыть без связей»">⇄ передачи</button>
<button class="btn" id="onlyLinked" type="button" aria-pressed="false" title="Убрать с доски карточки, у которых не осталось ни одной видимой стрелки">скрыть без связей</button>
<button class="btn" id="critPath" type="button" aria-pressed="false" title="Самая длинная цепочка блокировок по плановым датам окончания">⚑ критический путь</button>
<button class="btn" id="byDate" type="button" aria-pressed="false" title="Разложить карточки по колонкам плановых дат окончания. У этого режима своя расстановка: карточку можно двигать внутри её колонки; обычная расстановка не меняется и вернётся, когда режим выключен">▥ по датам</button>
</div>
<div class="notice" id="notice" hidden></div>
<div class="legend" id="legend">
<button type="button" class="lg" data-kind="decision"><span class="sw" style="background:var(--k-decision)"></span>решение</button>
<button type="button" class="lg" data-kind="analytics"><span class="sw" style="background:var(--k-analytics)"></span>аналитика / актуализация</button>
<button type="button" class="lg" data-kind="dev"><span class="sw" style="background:var(--k-dev)"></span>разработка</button>
<button type="button" class="lg" data-kind="ui"><span class="sw" style="background:var(--k-ui)"></span>UI</button>
<button type="button" class="lg" data-kind="test"><span class="sw" style="background:var(--k-test)"></span>тестирование</button>
<button type="button" class="lg" data-kind="org"><span class="sw" style="background:var(--k-org)"></span>организационная</button>
<span class="sep"></span>
<button type="button" class="lg" data-edge="block"><span class="ln"></span>блокирует начало → заблокирована</button>
<button type="button" class="lg" data-edge="rel"><span class="ln dash"></span>связанные</button>
<span><span class="ln hand"></span>передача другому исполнителю</span>
<span><span class="ln crit"></span>критический путь</span>
<span><span class="out-sw"></span>сосед по связям, сам под фильтры не попадает</span>
<span class="hint">щелчок по виду работы или типу связи скрывает их</span>
</div>
</div>
<div class="export" id="exportBox" hidden>
<div class="row"><span class="hint">JSON доски: все задачи файла, связи, раскладка, дорожки и фильтры. Загружается обратно ссылкой «заменить…». Если файл не скачался, скопируйте текст.</span><button class="btn" id="copyJson" type="button">Копировать</button><button class="btn" id="closeJson" type="button">Закрыть</button></div>
<textarea id="jsonText" readonly spellcheck="false"></textarea>
</div>
<div class="dates-bar" id="datesBar" hidden><div class="dates-in" id="datesIn"><div class="dates-track" id="datesTrack"></div></div></div>
<div class="stage" id="stage">
<div class="wrap" id="wrap"><div class="board" id="board"><svg class="edges" id="edges"></svg><svg class="guides" id="guides"></svg></div></div>
<aside class="drawer" id="problems" hidden><div class="drawer-head"><b>Проблемы на доске</b><button type="button" class="btn sm" id="probClose">Закрыть</button></div><div id="probBody"></div></aside>
</div>
<div class="hscroll" id="hscroll" hidden><div id="hscrollIn"></div></div>
<details id="tblBox" hidden><summary>Таблица связей</summary><div class="tbl"><table><thead><tr><th>Блокирует</th><th>Тип</th><th>Заблокирована</th></tr></thead><tbody id="tblBody"></tbody></table></div></details>
<script>
"use strict";
/* Разделы: 1 справочники и константы · 2 состояние и хранилище · 3 разбор файлов (CSV Jira, JSON доски) · 4 выборка и анализ
(фильтры, фокус на исполнителе, критический путь, проблемы) · 5 раскладка и отрисовка доски · 6 панель управления · 7 экспорт и запуск */
// ===== 1. Справочники и константы
const DEFAULT_CSV = "Ключ проблемы,Тема,Статус,Пользовательское поле (Связи Фичи),Приоритет,Исполнитель,Тип задачи,Пользовательское поле (Плановая дата окончания),Метки,Метки,Метки,Метки,Внешняя ссылка на проблему (ASUPR_Блокер_Начала),Внешняя ссылка на проблему (ASUPR_Блокер_Начала),Внешняя ссылка на проблему (ASUPR_Связанные),Внешняя ссылка на проблему (ASUPR_Связанные),Внешняя ссылка на проблему (Cloners),Спринт\nAURORA-1365,Гранулярность сопоставления ВГО — агрегат или строки 1:1,Ожидает анализа,AURORA-1357,Критичный,Lysenko-IS@rshbavt.ru,Задача,15/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,AURORA-1536,,,,,AWPCA_Sprint_1_Anal\nAURORA-1373,Шахматка по ТЗ 6.6,Анализ,AURORA-1357,Критичный,Lysenko-IS@rshbavt.ru,Задача,15/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-analytics,AURORA-1537,,,,,AWPCA_Sprint_1_Anal\nAURORA-1480,Модернизировать концепцию корректировок ,Анализ,AURORA-1358,Критичный,Starovoytov-AAlek@rshbavt.ru,Задача,16/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-analytics,AURORA-1395,AURORA-1396,,,AURORA-1366,AWPCA_Sprint_1_Anal\nAURORA-1536,\"Автосопоставление ВГО §5: правила elimination_rule как данные, версия правил, пары, расхождения\",Ожидание разработки,AURORA-1357,Критичный,Potapova-AIg,Задача,19/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,AURORA-1537,,,,,AWPCA_Sprint_2_VGO\nAURORA-1395,Форма корректировок ББ,Анализ,AURORA-1358,Критичный,Starovoytov-AAlek@rshbavt.ru,Задача,23/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-analytics,AURORA-1540,,,,,AWPCA_Sprint_2_VGO\nAURORA-1537,\"Шахматка §6: API матрицы по счёту/субсчёту, сводная таблица по статьям, переход в свод, экспорт xlsx\",Ожидание разработки,AURORA-1357,Критичный,Potapova-AIg,Задача,27/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,AURORA-1538,AURORA-1539,,,,AWPCA_Sprint_2_VGO\nAURORA-1540,\"Корректировки §8: модель correction, контроль 5 %, статусы DRAFT/APPLIED/REVOKED, импорт/экспорт xlsx, аудит ADJUSTMENT\",Ожидание разработки,AURORA-1358,Критичный,,Задача,02/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,,,,,,\nAURORA-1388,Блокировка шага до сопоставления контрагентов без ИНН,Ожидает анализа,AURORA-1357,Высокий,Lysenko-IS@rshbavt.ru,Задача,15/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,AURORA-1536,,,,,AWPCA_Sprint_2_VGO\nAURORA-1445,\" Экран «Проверка распознавания»: формы ББ/ОФР и распарсенная ОСВ, правка и подтверждение\",Разработка,AURORA-1353,Высокий,Stepanov-MAl,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-ui,,,AURORA-1535,,,AWPCA_Sprint_2_VGO\nAURORA-1410,Справочники: экраны ГК и организаций по макету,Разработка,AURORA-1351,Высокий,Golov-DS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-ui,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1444,\"Экраны: панель «Первичные документы», окно загрузки, чек-лист\",Разработка,AURORA-1353,Высокий,Stepanov-MAl,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-ui,,,AURORA-1534,AURORA-1535,,AWPCA_Sprint_2_VGO\nAURORA-1532,\"Сводная ОСВ: UI по макету [30], [32]\",Разработка,AURORA-1356,Высокий,Stepanov-MAl,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-in-progress,kind-ui,,,AURORA-1533,,,AWPCA_Sprint_2_VGO\nAURORA-1534,\"Загрузка и разбор ОСВ: тестирование на стенде (xlsx/xls/xlsb, дедуп, замена, автоподтверждение)\",Ожидание разработки,AURORA-1353,Высокий,Salmin-MP@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1396,Форма корректировок ОФР,Ожидает анализа,AURORA-1358,Высокий,Starovoytov-AAlek@rshbavt.ru,Задача,23/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,AURORA-1540,,,,,AWPCA_Sprint_1_Anal\nAURORA-1367,\"Статусная модель КФО: «Корректировка», маршрут, «Отклонено»/«Архив»\",Ожидает анализа,AURORA-1352,Высокий,Starovoytov-AAlek@rshbavt.ru,Задача,23/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-decision,,,,,,AWPCA_Sprint_1_Anal\nAURORA-1533,\"Сводная ОСВ: тестирование (контрольные цифры qa-guide §7.2, пробелы, ручное сопоставление)\",Ожидание разработки,AURORA-1356,Высокий,Salmin-MP@rshbavt.ru,Задача,24/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1535,\"Загрузка и распознавание ББ/ОФР: тестирование (OCR-пары fixtures/ocr, проверки §7, акцепт, подтверждение)\",Ожидание разработки,AURORA-1353,Высокий,Salmin-MP@rshbavt.ru,Задача,27/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1571,Перенос фронта arm-web в arm-core/web и сборка фронта в образ arm-core,Ожидание разработки,AURORA-1350,Высокий,,Задача,30/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,,,,,,\nAURORA-1538,Шахматка: UI по макету [31-01],Ожидание разработки,AURORA-1357,Высокий,Stepanov-MAl,Задача,30/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-ui,,,,,,\nAURORA-1539,Шахматка и ВГО: тестирование,Ожидание разработки,AURORA-1357,Высокий,Salmin-MP@rshbavt.ru,Задача,02/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,\nAURORA-1369,Гудвилл: база расчёта и ветка «без гудвилла»,Ожидает анализа,AURORA-1359,Высокий,,Задача,03/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,,,,,,\nAURORA-1404,Аутентификация через РАИСА/SSO,Ожидает анализа,AURORA-1529,Высокий,,Задача,06/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,,,,,,\nAURORA-1541,Корректировки: UI форм ББ и ОФР по макету [31-02],Ожидание разработки,AURORA-1358,Высокий,Stepanov-MAl,Задача,06/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-ui,,,,,,\nAURORA-1366,\"Ролевая модель: свести ТЗ, аналитику и макет в одну\",Ожидает анализа,AURORA-1529,Высокий,Starovoytov-AAlek@rshbavt.ru,Задача,06/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-analytics,,,,,,AWPCA_Sprint_1_Anal\nAURORA-1543,\"Гудвилл §8: расчёт по правилу № 7, таблица goodwill, ветка «без гудвилла», переключатель на вкладке\",Ожидание разработки,AURORA-1359,Высокий,,Задача,09/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-dev,,,,,,\nAURORA-1394,Контрольные пороги по итогам консолидации,Ожидает анализа,AURORA-1360,Высокий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,,,,,,\nAURORA-1374,Консолидированный ОФР (Прил. 4),Ожидает анализа,AURORA-1360,Высокий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,,,,,,\nAURORA-1375,Результат КФО и форма Прил. 10,Ожидает анализа,AURORA-1360,Высокий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,,,,,,\nAURORA-1542,Расхождения и корректировки: тестирование,Ожидание разработки,AURORA-1358,Высокий,Salmin-MP@rshbavt.ru,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-test,,,,,,\nAURORA-1574,\"Фикстуры: реальные xlsb и Р7 от заказчика, ОСВ расширенного подхода, формы без колонки кодов\",Ожидание разработки,AURORA-1354,Средний,Epifantsev-AA@rshbavt.ru,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-org,AURORA-1534,,,,,AWPCA_Sprint_2_VGO\nAURORA-1380,Парсинг ББ/ОФР из Excel/Р7 и архитектура сервисов,Ожидает анализа,AURORA-1354,Средний,Lysenko-IS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1583,,,AWPCA_Sprint_1_Anal\nAURORA-1378,Замена и удаление первичного документа,Ожидает анализа,AURORA-1353,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1534,,,AWPCA_Sprint_2_VGO\nAURORA-1379,Чек-лист документов: блокировка и формат,Ожидает анализа,AURORA-1353,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1534,,,AWPCA_Sprint_2_VGO\nAURORA-1582,\"Актуализировать страницу «Справочник ГК и организации» (DOC-221280) по Срезу 1: КПП, ГСК+ГСЗ одновременно, импорт ЕЦП §13, code/crm_id\",Ожидает анализа,AURORA-1351,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-docs,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1371,Сводная ОСВ: свёртка до счёт–субсчёт–контрагент,Ожидает анализа,AURORA-1356,Средний,Lysenko-IS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1533,,,AWPCA_Sprint_2_VGO\nAURORA-1372,Признак контрагента: сопоставление вместо переопределения,Ожидает анализа,AURORA-1356,Средний,Lysenko-IS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,AURORA-1533,,,AWPCA_Sprint_2_VGO\nAURORA-1370,Единая статусная модель документа,Ожидает анализа,AURORA-1353,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,,,,AWPCA_Sprint_1_Anal\nAURORA-1381,Акцепт расхождений и комментарии к отчётности,Ожидает анализа,AURORA-1355,Средний,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-docs,kind-docs,,,,,,AWPCA_Sprint_1_Anal\nAURORA-1446,\"Комментарии к объектам (документ, ячейка шахматки, строка КФО)\",Ожидание разработки,AURORA-1353,Средний,,Задача,22/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-dev,,,AURORA-1537,AURORA-1445,,AWPCA_Sprint_2_VGO\nAURORA-1442,\"Жизненный цикл документа: проверки периода/контрагента, подтверждение, повторная обработка, замена и удаление\",Ожидание разработки,AURORA-1353,Средний,,Задача,23/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-dev,,,AURORA-1370,,,AWPCA_Sprint_2_VGO\nAURORA-1376,Автопересчёт консолидации,Ожидает анализа,AURORA-1352,Средний,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,,,,,,\nAURORA-1383,Данные предыдущих периодов,Ожидает анализа,AURORA-1360,Средний,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-analytics,,,,,,\nAURORA-1218,Реализовать миграции данных. Этап 1,Готово к тестированию,AURORA-1350,Низкий,Stepanov-MAl,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1553,Логические проверки ББ/ОФР §7 в ядре и акцепт нарушений,Готово к тестированию,AURORA-1355,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1409,\"Справочники: CRUD ГК, ЮЛ/ИП, ГСК/ГСЗ и состав групп с историей\",Готово к тестированию,AURORA-1351,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1411,Алиасы контрагентов: справочник и разрешение конфликтов,Готово к тестированию,AURORA-1351,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1450,scan-gateway: передача документа в ИС Сканирование и получение результата,Готово к тестированию,AURORA-1354,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1451,scan-gateway: нормализация JSON ИС Сканирование во внутреннюю схему формы,Готово к тестированию,AURORA-1354,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1448,ББ и ОФР из Excel / Р7 в единую схему формы,Готово к тестированию,AURORA-1354,Низкий,,Задача,31/08/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1531,\"Сводная ОСВ: API, склейка периодов, признак контрагента, ручное сопоставление, экспорт xlsx\",Готово к тестированию,AURORA-1356,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1413,\"Консолидация: создание, версии, периметр, привязка документов\",Готово к тестированию,AURORA-1352,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1441,\"Маппер результатов распознавания в БД (ОСВ и формы), порог 100 % и метрики качества\",Готово к тестированию,AURORA-1353,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1440,Оркестрация распознавания через recognition_job (outbox / inbox NATS),Готово к тестированию,AURORA-1353,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1439,\"Загрузка первичных документов: stored_file, S3, дедуп, определение типа\",Готово к тестированию,AURORA-1353,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1443,Чек-лист комплектности документов версии,Готово к тестированию,AURORA-1353,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1447,tabular-parser: табличная часть ОСВ — дерево строк по уровням группировки и контрольные суммы,Готово к тестированию,AURORA-1354,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1415,Блокировка «взять в работу» и перехват прав,Готово к тестированию,AURORA-1352,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1449,\"tabular-parser: поддержка xls, xlsb, Р7 и определение формата по содержимому\",Готово к тестированию,AURORA-1354,Низкий,,Задача,09/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-done,kind-dev,,,,,,\nAURORA-1583,\"Актуализировать FNR01/FNR02 (DOC-037740, DOC-221275) по контракту v1.2 и горячим папкам\",Ожидает анализа,AURORA-1354,Низкий,Lysenko-IS@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-docs,,,,,,AWPCA_Sprint_2_VGO\nAURORA-1585,Актуализировать «Пользовательский сценарий» (DOC-037736) и «Пользовательские сценарии подробно» (DOC-221274) по пути пользователя 10-user-journey.md,Ожидает анализа,AURORA-1364,Низкий,Starovoytov-AAlek@rshbavt.ru,Задача,20/10/2026 00:00,actualization-2026-10-10,AWPCA,fact-open,kind-docs,,,,,,\nAURORA-1392,Базовый и расширенный подход,Ожидает анализа,AURORA-1352,Низкий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-partial,kind-analytics,,,,,,\nAURORA-1403,Расшифровки участника — входят ли в этап,Ожидает анализа,AURORA-1360,Низкий,,Задача,10/11/2026 00:00,actualization-2026-10-10,AWPCA,fact-decision,kind-decision,,,,,,\n";
const DEFAULT_NAME = "Анализ спринтов (СУЖЦ ПО) 2026-10-11 13:11 (встроенная выгрузка)";
const JIRA = "https://sdlc.go.rshbank.ru/jira";
// ключ эпика → название дорожки; строки типа «Фича» в файле дополняют список
const EPICS = {"AURORA-1350": "Архитектура и инфраструктура", "AURORA-1351": "Справочники ГК / ГСК / ГСЗ / ЮЛ", "AURORA-1352": "Консолидация: карточка, версии, статусы", "AURORA-1353": "Загрузка документов и распознавание", "AURORA-1354": "Сервисы распознавания и парсинга", "AURORA-1355": "Логические проверки отчётности", "AURORA-1356": "Сводная ОСВ", "AURORA-1357": "Сопоставление ВГО и Шахматка", "AURORA-1358": "Расхождения и корректировки", "AURORA-1359": "Гудвилл", "AURORA-1360": "Формирование КФО", "AURORA-1361": "Экспорт", "AURORA-1362": "Журнал аудита", "AURORA-1363": "Единое хранилище (ЕХФД)", "AURORA-1364": "Аналитика", "AURORA-1529": "Роли, доступ и аутентификация (SSO)", "AURORA-1530": "Приёмка и передача системы (тестирование КА, ПСИ)"};
const KIND_LABELS = {"kind-decision": "решение", "kind-analytics": "аналитика", "kind-docs": "актуализация", "kind-dev": "разработка", "kind-ui": "UI", "kind-test": "тестирование", "kind-org": "организационная"}; // метка Jira → вид работы
const KIND = {"решение": "decision", "аналитика": "analytics", "актуализация": "analytics", "разработка": "dev", "UI": "ui", "тестирование": "test", "организационная": "org"}; // вид работы → цвет и фильтр легенды
const PRIO = {"Критичный": "crit", "Наивысший": "crit", "Высокий": "high", "Средний": "mid", "Низкий": "low", "Самый низкий": "low"};
const LINK_TYPES = {"ASUPR_Блокер_Начала": "блокирует начало", "ASUPR_Блокер_Завершения": "блокирует завершение", "ASUPR_Связанные": "связанные"};
const RELATED = "связанные"; // единственный ненаправленный тип связи
const NO_EPIC = "Без эпика", NOT_IN_FILE = "Нет в файле"; // служебные дорожки, всегда внизу
const KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
// геометрия доски, px: карточка, колонка слоя, отступы, свёрнутая дорожка, сетка и порог примагничивания
const CW = 240, COLW = 296, LEFT = 24, TOP = 40, MAXROWS = 4, FOLD_H = 36, GRID = 8, SNAP = 6, MINW = 160, MINH = 80, TITLE_LH = 19;
const NS = "http://www.w3.org/2000/svg";
const ARROWS = `<defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="var(--edge-strong)"/></marker><marker id="arrHand" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="var(--guide)"/></marker></defs>`;
const $ = id => document.getElementById(id);
const board = $("board"), edgesSvg = $("edges"), guidesSvg = $("guides");
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const plural = (n, one, few, many) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? one : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? few : many}`;
const fmtDate = d => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d); return m ? `${m[3]}.${m[2]}` : d; };
const dayDiff = (a, b) => (!a || !b) ? null : Math.round((new Date(b) - new Date(a)) / 86400000);
const notStarted = status => /ожида|backlog|бэклог|к выполнению|to do|open/i.test(status || "");
const unassigned = w => !w || /^(не назначен|unassigned)$/i.test(w);
const whoKey = w => unassigned(w) ? "" : w.trim();
// «Степанов Максим Александрович» → «Степанов М. А.»; логины вида Stepanov-MAl остаются как есть
function shortName(w) {
if (unassigned(w)) return "не назначен";
const parts = w.trim().split(/\s+/); if (parts.length < 2) return w;
return parts[0] + " " + parts.slice(1, 3).map(x => x[0].toUpperCase() + ".").join(" ");
}
// даты Jira в разных локалях → ГГГГ-ММ-ДД, чтобы сравнивать строками
function sortableDate(d) {
let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d); if (m) return d.slice(0, 10);
m = /^(\d{1,2})[./](\d{1,2})[./](\d{4})/.exec(d); if (m) return `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
m = /^(\d{1,2})\/([A-Za-zА-Яа-я]{3})\/(\d{2})(\d{2})?/.exec(d);
if (m) {
const mon = { jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", jul: "07", aug: "08", sep: "09", oct: "10", nov: "11", dec: "12", янв: "01", фев: "02", мар: "03", апр: "04", май: "05", мая: "05", июн: "06", июл: "07", авг: "08", сен: "09", окт: "10", ноя: "11", дек: "12" }[m[2].toLowerCase()];
if (mon) return `${m[4] ? m[3] + m[4] : "20" + m[3]}-${mon}-${m[1].padStart(2, "0")}`;
}
return d;
}
// ===== 2. Состояние и хранилище
// MODEL — загруженный файл: nodes {key,title,kind,prio,due,status,epic,sprint,who,stub}, links {src,dst,type}, sprints {имя: число}, fileName, problems, hasSprint, missing
let MODEL = null;
// фильтры и переключатели. Три слоя: фильтры задач (sprints, who, hideKind — пересечение, пустое множество = без фильтра) и связей
// (hideEdge, onlyHand — только стрелки); контекст neighbors — расширение соседями по связям; onlyLinked убирает карточки без видимых стрелок;
// crit — подсветка, byDate — раскладка
const S = { neighbors: false, onlyLinked: false, onlyHand: false, crit: false, byDate: false, snap: true, sprints: new Set(), who: new Set(), hideKind: new Set(), hideEdge: new Set() };
// VIS — текущая выборка: nodes (+dim, focus, crit), links (+hand, crit), lanes [{name, keys}], removed {…}, problems
let VIS = { nodes: [], links: [], lanes: [], removed: {}, problems: null };
// раскладка: rel[key] = {x, ry, w, h, auto, other} относительно дорожки; pos[key] — абсолютно на доске (нет у карточек свёрнутых дорожек)
let rel = {}, pos = {}, laneOf = {}, lanesGeom = [], cardEls = {}, baseW = 0, dateEls = [], colBounds = {}; // colBounds[key] = {x0, x1} колонки даты (только в режиме по датам)
// localStorage: общие настройки «graph-<имя>», настройки доски «graph-<имя>:board» (раскладка, дорожки, фильтры). Ключ не зависит от имени
// файла: карточки адресуются ключами Jira, поэтому расстановка переживает замену CSV новой выгрузкой. Чтение и запись защищены от приватного режима
const store = {
get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : v; } catch (e) { return d; } },
set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} },
del(k) { try { localStorage.removeItem(k); } catch (e) {} },
json(k, d) { try { const v = JSON.parse(this.get(k, "null")); return v === null ? d : v; } catch (e) { return d; } },
};
const pref = name => "graph-" + name;
const BOARD = "board", BOARD_KEYS = ["pos", "lanes", "fold", "sprint", "who"];
const posKey = () => S.byDate ? "posdate" : "pos"; // у обычной раскладки и раскладки «по датам» свои хранилища
const fileKey = name => "graph-" + name + ":" + BOARD;
// до 11.10.2026 настройки лежали под именем файла («graph-pos:<файл>») и терялись при каждой новой выгрузке; при первом запуске новой версии
// переносим на общий ключ набор того файла, где расставлено больше всего карточек (при равенстве — не встроенного примера)
function migrateStore() {
if (store.get(fileKey("pos"), null) !== null) return;
let names = [];
try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith("graph-pos:") && !k.endsWith(":" + BOARD)) names.push(k.slice("graph-pos:".length)); } } catch (e) { return; }
if (!names.length) return;
const size = n => Object.keys(store.json("graph-pos:" + n, {}) || {}).length;
names.sort((a, b) => size(b) - size(a) || (a === DEFAULT_NAME) - (b === DEFAULT_NAME));
BOARD_KEYS.forEach(k => { const v = store.get("graph-" + k + ":" + names[0], null); if (v !== null) store.set(fileKey(k), v); });
}
const strList = v => Array.isArray(v) ? v.filter(s => typeof s === "string") : [];
const laneOrder = () => strList(store.json(fileKey("lanes"), []));
const foldedSet = () => new Set(strList(store.json(fileKey("fold"), [])));
// сохраняем поверх уже записанного: карточки, скрытые текущим фильтром, не теряют свои места
// в режиме «по датам» x хранится относительно левой границы колонки даты: при сдвиге границ карточки едут вместе с колонкой
function saveLayout() {
const out = store.json(fileKey(posKey()), {}) || {};
Object.entries(rel).forEach(([k, r]) => { const x = r.x - (colBounds[k] ? colBounds[k].x0 : 0); out[k] = r.auto ? { x, ry: r.ry, w: r.w } : { x, ry: r.ry, w: r.w, h: r.h }; });
store.set(fileKey(posKey()), JSON.stringify(out));
}
// ширины колонок дат, заданные вручную: {дата: px}; меньше COLW не бывает
const colWidths = () => { const v = store.json(fileKey("colw"), {}); return v && typeof v === "object" ? v : {}; };
// в режиме «по датам» карточка не выходит за свою колонку даты: x и ширина зажимаются по границам колонки
const CPAD = 6;
function clampToCol(k, x, w) {
const b = colBounds[k]; if (!b) return { x, w };
w = Math.max(MINW, Math.min(w, b.x1 - b.x0 - 2 * CPAD));
return { x: Math.max(b.x0 + CPAD, Math.min(x, b.x1 - CPAD - w)), w };
}
// ===== 3. Разбор файлов
function parseCsv(text) {
const rows = []; let row = [], cell = "", q = false;
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) { if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else q = false; } else cell += ch; continue; }
if (ch === '"') q = true;
else if (ch === ",") { row.push(cell); cell = ""; }
else if (ch === "\n" || ch === "\r") { if (ch === "\r" && text[i + 1] === "\n") i++; row.push(cell); rows.push(row); row = []; cell = ""; }
else cell += ch;
}
if (cell !== "" || row.length) { row.push(cell); rows.push(row); }
return rows.filter(r => r.some(c => c !== ""));
}
const stubNode = key => ({ key, title: "нет в файле", kind: "", prio: "", due: "", status: "", epic: NOT_IN_FILE, sprint: "", stub: true, who: "" });
// связи без дублей; для «связанных» порядок концов не важен
function addLink(links, seen, src, dst, type) {
const id = type === RELATED ? [src, dst].sort().join("|") + type : src + ">" + dst + type;
if (seen.has(id)) return; seen.add(id); links.push({ src, dst, type });
}
// задачи, упомянутые в связях, но отсутствующие в файле, добавляются заглушками на дорожке «Нет в файле»
function addStubs(nodes, links) {
const keys = new Set(nodes.map(n => n.key)), missing = new Set();
links.forEach(l => [l.src, l.dst].forEach(k => { if (!keys.has(k)) missing.add(k); }));
missing.forEach(k => nodes.push(stubNode(k)));
return missing.size;
}
// CSV из фильтра Jira: нужны колонки Ключ проблемы, Тема, Статус, Приоритет, Тип задачи, Метки, Связи Фичи, Плановая дата окончания, Исполнитель, Спринт
// и «Внешняя/Внутренняя ссылка на проблему (ASUPR_…)». У задачи X «Внешняя ссылка (ASUPR_Блокер_Начала)» содержит Y, если X блокирует Y
// (outward-описание «Блокирует начало выполнения»); «Внутренняя ссылка» — если Y блокирует X. До 11.10.2026 связи в Jira стояли
// задом наперёд (ошибка направления в jira_sync.py), и страница читала «Внешняя» как блокеров; 11.10.2026 связи пересозданы, чтение исправлено.
function fromJiraCsv(text, fileName) {
const rows = parseCsv(text);
if (rows.length < 2) throw new Error("в файле нет строк с задачами");
const head = rows[0].map(h => h.trim());
const find = (...subs) => head.findIndex(h => subs.some(s => h.toLowerCase().includes(s.toLowerCase())));
const cols = {
key: find("Ключ проблемы", "Ключ задачи", "Issue key", "Ключ"), sum: find("Тема", "Резюме", "Summary"), status: find("Статус", "Status"),
prio: find("Приоритет", "Priority"), type: find("Тип задачи", "Тип запроса", "Issue Type"), epic: find("Связи Фичи"),
due: find("Плановая дата окончания", "Срок исполнения", "Due date"), who: find("Исполнитель", "Assignee"),
};
const labelCols = head.map((h, i) => (/^(Метки|Labels)$/i.test(h) ? i : -1)).filter(i => i >= 0);
const sprintCols = head.map((h, i) => (/^(Спринт|Sprint)$/i.test(h) ? i : -1)).filter(i => i >= 0);
if (cols.key < 0 || cols.sum < 0) throw new Error("не нашёл колонки «Ключ проблемы» и «Тема»; в файле: " + head.slice(0, 10).join(", ") + "…");
const linkCols = [];
head.forEach((h, i) => { for (const [t, type] of Object.entries(LINK_TYPES)) if (h.includes(t)) linkCols.push({ i, type, out: /outward|исход|внешн/.test(h.toLowerCase()) }); });
const problems = [];
if (!linkCols.length) problems.push("в файле нет колонок связей — в фильтре Jira добавьте колонку «Связанные задачи» или экспортируйте «все поля»");
if (!sprintCols.length) problems.push("в файле нет колонки «Спринт» — выбор спринта недоступен, все задачи считаются выбранными");
if (cols.epic < 0) problems.push("нет колонки «Связи Фичи» — дорожки по эпикам не построить");
const cell = (r, i) => (i >= 0 ? r[i] || "" : "").trim();
const isEpic = r => cols.type >= 0 && /^(Фича|Epic|Эпик)$/i.test(cell(r, cols.type));
const epicNames = { ...EPICS };
rows.slice(1).forEach(r => { const k = cell(r, cols.key); if (KEY_RE.test(k) && isEpic(r)) epicNames[k] = cell(r, cols.sum) || k; });
const nodes = [], links = [], seen = new Set(), sprints = {};
for (const r of rows.slice(1)) {
const key = cell(r, cols.key); if (!KEY_RE.test(key) || isEpic(r)) continue;
let kind = cell(r, cols.type);
for (const c of labelCols) { const v = cell(r, c); if (KIND_LABELS[v]) kind = KIND_LABELS[v]; }
const epicKey = cell(r, cols.epic);
const sprint = sprintCols.map(c => cell(r, c)).filter(Boolean).pop() || "";
if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
nodes.push({ key, title: cell(r, cols.sum), kind, prio: cell(r, cols.prio), due: sortableDate(cell(r, cols.due)), status: cell(r, cols.status),
epic: epicKey ? (epicNames[epicKey] || epicKey) : NO_EPIC, sprint, who: cell(r, cols.who), stub: false });
for (const lc of linkCols) {
const other = cell(r, lc.i); if (!KEY_RE.test(other)) continue;
addLink(links, seen, lc.out ? key : other, lc.out ? other : key, lc.type);
}
}
const missing = addStubs(nodes, links);
return { nodes, links, sprints, fileName, problems, hasSprint: sprintCols.length > 0, missing };
}
// JSON, выгруженный этой же страницей (см. exportJson): задачи, связи, раскладка, порядок и свёртка дорожек, фильтры
function fromJson(text, fileName) {
let d; try { d = JSON.parse(text); } catch (e) { throw new Error("это не JSON"); }
if (!d || !Array.isArray(d.nodes)) throw new Error("в JSON нет списка nodes — это не выгрузка этой страницы");
const keys = new Set(), nodes = [], links = [], seen = new Set(), sprints = {}, layout = {}, layoutByDate = {};
d.nodes.forEach(n => {
const key = String(n && n.key || "").trim(); if (!KEY_RE.test(key) || keys.has(key) || n.stub) return; keys.add(key);
const sprint = String(n.sprint || "").trim(); if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
nodes.push({ key, title: String(n.title || ""), kind: String(n.kind || ""), prio: String(n.priority || ""), due: sortableDate(String(n.due || "")),
status: String(n.status || ""), epic: String(n.epic || NO_EPIC), sprint, who: String(n.assignee || ""), stub: false });
const take = L => L && typeof L.x === "number" && typeof L.ry === "number" ? { x: L.x, ry: L.ry, w: typeof L.w === "number" ? L.w : CW, ...(typeof L.h === "number" ? { h: L.h } : {}) } : null;
const L = take(n.layout), LD = take(n.layoutByDate);
if (L) layout[key] = L;
if (LD) layoutByDate[key] = LD;
});
if (!nodes.length) throw new Error("в nodes нет ни одной задачи с ключом вида AURORA-1234");
const types = new Set(Object.values(LINK_TYPES));
(Array.isArray(d.links) ? d.links : []).forEach(l => {
const src = String(l && l.from || "").trim(), dst = String(l && l.to || "").trim(); if (!KEY_RE.test(src) || !KEY_RE.test(dst)) return;
addLink(links, seen, src, dst, types.has(l.type) ? l.type : "блокирует начало");
});
const missing = addStubs(nodes, links);
const f = d.filters && typeof d.filters === "object" ? d.filters : {};
return { model: { nodes, links, sprints, fileName: String(d.source || fileName), problems: [], hasSprint: nodes.some(n => n.sprint), missing },
layout, layoutByDate, dateColumns: d.dateColumns && typeof d.dateColumns === "object" ? d.dateColumns : null, laneOrder: strList(d.laneOrder), collapsedLanes: Array.isArray(d.collapsedLanes) ? strList(d.collapsedLanes) : null, filters: f };
}
// ===== 4. Выборка и анализ
// фильтры задач — пересечение спринта, исполнителя и вида работы; skip — какой из них не учитывать (для счётчиков в выпадашках).
// Заглушки «нет в файле» фильтры не проходят: они попадают на доску только как соседи по связям
const passes = (x, skip) => !x.stub
&& (skip === "sprint" || S.sprints.size === 0 || S.sprints.has(x.sprint))
&& (skip === "who" || S.who.size === 0 || S.who.has(whoKey(x.who)))
&& (skip === "kind" || !S.hideKind.has(KIND[x.kind] || "org"));
const edgeClass = l => (l.type === RELATED ? "rel" : "block");
// доска: ядро = задачи, прошедшие фильтры; контекст = их соседи по связям (если включён); стрелки = все связи между карточками на доске
// минус скрытые типы и, при «передачах», минус связи внутри одного человека; «скрыть без связей» убирает карточки без оставшихся стрелок.
// removed — сколько карточек/стрелок убрал каждый шаг, для строки статуса и объяснения пустой доски
function computeVisible() {
const all = MODEL.nodes, byKey = Object.fromEntries(all.map(x => [x.key, x]));
const isHand = l => l.type !== RELATED && whoKey(byKey[l.src].who) !== whoKey(byKey[l.dst].who);
const core = new Set(all.filter(x => passes(x)).map(x => x.key));
let keep = new Set(core);
if (S.neighbors) MODEL.links.forEach(l => { if (core.has(l.src) || core.has(l.dst)) { keep.add(l.src); keep.add(l.dst); } });
const removed = { core: core.size, ctx: keep.size - core.size, edge: 0, loose: 0 };
let links = MODEL.links.filter(l => keep.has(l.src) && keep.has(l.dst));
const allLinks = links.length;
if (S.hideEdge.size) links = links.filter(l => !S.hideEdge.has(edgeClass(l)));
if (S.onlyHand) links = links.filter(isHand);
removed.edge = allLinks - links.length;
if (S.onlyLinked) { const ends = new Set(links.flatMap(l => [l.src, l.dst])); const before = keep.size; keep = new Set([...keep].filter(k => ends.has(k))); removed.loose = before - keep.size; }
links = links.map(l => ({ ...l, hand: isHand(l) }));
// ядро сплошной рамкой, соседи пунктиром
const nodes = all.filter(x => keep.has(x.key)).map(x => ({ ...x, dim: !core.has(x.key), focus: false }));
const crit = S.crit ? criticalPath(nodes, links) : { nodes: new Set(), edges: new Set() };
nodes.forEach(x => { x.crit = crit.nodes.has(x.key); });
links.forEach(l => { l.crit = crit.edges.has(l.src + ">" + l.dst); });
// дорожки по эпикам: по алфавиту, служебные внизу, затем сохранённый порядок пользователя
const byEpic = {}; nodes.forEach(x => (byEpic[x.epic] = byEpic[x.epic] || []).push(x.key));
const tail = name => name === NO_EPIC || name === NOT_IN_FILE;
const lanes = Object.entries(byEpic).sort((a, b) => tail(a[0]) - tail(b[0]) || a[0].localeCompare(b[0], "ru")).map(([name, keys]) => ({ name, keys }));
const order = laneOrder();
if (order.length) lanes.sort((a, b) => { const ia = order.indexOf(a.name), ib = order.indexOf(b.name); return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib); });
return { nodes, links, lanes, removed, problems: findProblems(nodes, links) };
}
// критический путь: самая длинная цепочка блокировок; при равной длине — с наименьшим запасом по плановым датам окончания
function criticalPath(nodes, links) {
const byKey = Object.fromEntries(nodes.map(n => [n.key, n])), out = {}; nodes.forEach(n => out[n.key] = []);
links.filter(l => l.type !== RELATED).forEach(l => out[l.src].push(l.dst));
const memo = {};
const best = (k, seen = new Set()) => { // {len, slack, path} от k; запас = сумма (срок зависимой − срок блокера) по рёбрам
if (memo[k]) return memo[k]; if (seen.has(k)) return { len: 0, slack: 0, path: [k] }; seen.add(k);
let r = { len: 0, slack: 0, path: [k] };
out[k].forEach(d => {
const b = best(d, seen), sl = dayDiff(byKey[k].due, byKey[d].due);
const cand = { len: b.len + 1, slack: b.slack + (sl === null ? 0 : Math.max(0, sl)), path: [k, ...b.path] };
if (cand.len > r.len || (cand.len === r.len && cand.slack < r.slack)) r = cand;
});
memo[k] = r; return r;
};
let top = null; nodes.forEach(n => { const b = best(n.key); if (!top || b.len > top.len || (b.len === top.len && b.slack < top.slack)) top = b; });
const res = { nodes: new Set(), edges: new Set() };
if (top && top.len > 0) { top.path.forEach(k => res.nodes.add(k)); for (let i = 0; i + 1 < top.path.length; i++) res.edges.add(top.path[i] + ">" + top.path[i + 1]); }
return res;
}
// точки риска по блокирующим связям видимой части доски
function findProblems(nodes, links) {
const byKey = Object.fromEntries(nodes.map(n => [n.key, n])), today = new Date().toISOString().slice(0, 10);
const dates = [], unassignedList = [], late = [], hand = [];
links.filter(l => l.type !== RELATED).forEach(l => {
const a = byKey[l.src], b = byKey[l.dst]; if (a.stub || b.stub) return;
const sl = dayDiff(a.due, b.due);
if (sl !== null && sl <= 0) dates.push({ a, b, text: sl === 0 ? `сроки совпадают (${fmtDate(a.due)}): запаса нет` : `блокер должен закончиться ${fmtDate(a.due)}, а зависимая — уже ${fmtDate(b.due)}` });
if (!whoKey(a.who)) unassignedList.push({ a, b, text: whoKey(b.who) ? `${shortName(b.who)} ждёт задачу без исполнителя` : "блокер без исполнителя" });
const left = dayDiff(today, b.due);
if (notStarted(a.status) && left !== null && left <= 7) late.push({ a, b, text: `блокер в статусе «${a.status}», а до срока зависимой ${left < 0 ? "срок прошёл" : left + " дн."}` });
if (l.hand && whoKey(a.who) && whoKey(b.who)) hand.push({ a, b, text: `${shortName(b.who)} ждёт ${shortName(a.who)}` });
});
return { dates, unassigned: unassignedList, late, hand };
}
function emptyReason() {
const r = VIS.removed;
if (!MODEL.nodes.some(n => !n.stub)) return "В файле нет задач.";
if (!r.core) {
const active = [S.sprints.size ? "спринт" : "", S.who.size ? "исполнитель" : "", S.hideKind.size ? "виды работ в легенде" : ""].filter(Boolean);
return `Фильтры не оставили ни одной задачи (${active.join(", ")}). Ослабьте один из них: «Снять выбор» в выпадашке или щелчок по зачёркнутому виду работы.`;
}
if (r.loose) return `Все ${r.loose} задач скрыты кнопкой «скрыть без связей»: ${S.onlyHand ? "передач между людьми на доске нет" : "видимых стрелок у них нет"}. Отключите её${S.onlyHand ? " или «передачи»" : S.hideEdge.size ? " или верните тип связи в легенде" : ""}.`;
return "Нет задач для показа.";
}
// ===== 5. Раскладка и отрисовка доски
// слой задачи = длина самой длинной цепочки блокеров до неё; внутри дорожки карточки слоя стоят колонкой, не больше MAXROWS в подколонке.
// В режиме «по датам» слой = порядковый номер плановой даты окончания среди видимых задач (без срока — последняя колонка), раскладка берётся из своего хранилища
function render() {
VIS = computeVisible();
const data = VIS;
board.querySelectorAll(".lane,.card,.empty,.dcol").forEach(e => e.remove());
rel = {}; pos = {}; laneOf = {}; lanesGeom = []; cardEls = {}; dateEls = []; colBounds = {};
board.classList.toggle("bydate", S.byDate); $("stage").classList.toggle("bydate", S.byDate && data.nodes.length > 0);
$("datesBar").hidden = !S.byDate || !data.nodes.length;
renderProblems(); renderTable();
if (!data.nodes.length) {
board.style.width = "100%"; board.style.height = "auto";
const d = document.createElement("div"); d.className = "empty"; d.textContent = emptyReason(); board.appendChild(d);
edgesSvg.innerHTML = ARROWS; hscrollEl.hidden = true; return;
}
const byKey = Object.fromEntries(data.nodes.map(n => [n.key, n]));
const preds = {}; data.nodes.forEach(n => preds[n.key] = []); data.links.forEach(l => preds[l.dst].push(l.src));
const layer = {};
const L = (k, seen = new Set()) => { if (layer[k] !== undefined) return layer[k]; if (seen.has(k)) return 0; seen.add(k); layer[k] = preds[k].length ? Math.max(...preds[k].map(p => L(p, seen))) + 1 : 0; return layer[k]; };
const dateCols = S.byDate ? [...new Set(data.nodes.map(n => n.due || ""))].sort((a, b) => (a || "~").localeCompare(b || "~")) : null;
if (dateCols) data.nodes.forEach(n => { layer[n.key] = dateCols.indexOf(n.due || ""); }); else data.nodes.forEach(n => L(n.key));
const maxLayer = Math.max(0, ...Object.values(layer)), layerCols = Array(maxLayer + 1).fill(1);
data.lanes.forEach(lane => { const cnt = {}; lane.keys.forEach(k => { cnt[layer[k]] = (cnt[layer[k]] || 0) + 1; }); Object.entries(cnt).forEach(([c, n]) => { layerCols[c] = Math.max(layerCols[c], Math.ceil(n / MAXROWS)); }); });
const cw = dateCols ? colWidths() : {};
const colW = c => dateCols && typeof cw[dateCols[c]] === "number" ? Math.max(layerCols[c] * COLW, cw[dateCols[c]]) : layerCols[c] * COLW; // ручная ширина не уже автоматической
const layerX = []; let x = LEFT; for (let c = 0; c <= maxLayer; c++) { layerX[c] = x; x += colW(c); }
baseW = x;
const stacks = []; // подколонки: карточки сверху вниз, высота — по содержимому (считается после отрисовки)
data.lanes.forEach((lane, li) => {
const cols = {};
lane.keys.forEach(k => { (cols[layer[k]] = cols[layer[k]] || []).push(k); laneOf[k] = li; });
Object.entries(cols).forEach(([c, ks]) => {
ks.sort((a, b) => ((byKey[a].due || "9") > (byKey[b].due || "9") ? 1 : -1));
const per = Math.ceil(ks.length / Math.ceil(ks.length / MAXROWS)), sub = [];
ks.forEach((k, i) => { const col = Math.floor(i / per); rel[k] = { x: layerX[c] + col * COLW, ry: TOP, w: CW, h: MINH, auto: true }; (sub[col] = sub[col] || []).push(k); });
stacks.push(...sub);
});
lanesGeom.push({ name: lane.name, y: 0, h: 0, minH: TOP + MINH + 24, folded: false, el: null });
});
lanesGeom.forEach((g, li) => renderLane(g, li, data.lanes[li].keys.length));
if (dateCols) { renderDateCols(dateCols, layerX, colW, data.nodes); data.nodes.forEach(n => { const c = layer[n.key], x0 = layerX[c] - LEFT / 2; colBounds[n.key] = { x0, x1: x0 + colW(c) }; }); }
const multiSprint = new Set(data.nodes.filter(n => !n.stub).map(n => n.sprint)).size > 1; // метка спринта нужна, только если спринтов на доске больше одного
data.nodes.forEach(n => renderCard(n, multiSprint));
// высота по содержимому: снимаем ограничение, измеряем, округляем к сетке; other — высота всего, кроме заголовка (для обрезки заголовка при ручной высоте)
data.nodes.forEach(n => { const c = cardEls[n.key], t = c.querySelector(".title"); c.style.width = rel[n.key].w + "px"; c.style.height = "auto"; t.style.webkitLineClamp = "unset"; rel[n.key].h = measuredHeight(c); rel[n.key].other = c.offsetHeight - t.offsetHeight; });
stacks.forEach(list => { let y = TOP; list.forEach(k => { rel[k].ry = y; y += rel[k].h + 16; }); });
const saved = store.json(fileKey(posKey()), null);
if (saved && typeof saved === "object") Object.entries(saved).forEach(([k, v]) => {
if (!rel[k] || typeof v.x !== "number" || typeof v.ry !== "number") return;
const base = colBounds[k] ? colBounds[k].x0 : 0;
const manualH = typeof v.h === "number", c = clampToCol(k, Math.max(0, base + v.x), typeof v.w === "number" ? v.w : rel[k].w); // дата могла смениться — карточка остаётся в своей колонке
rel[k] = { x: c.x, ry: Math.max(TOP, v.ry), w: c.w, h: manualH ? v.h : rel[k].h, auto: !manualH, other: rel[k].other };
});
relayout(); drawEdges();
}
const measuredHeight = c => Math.max(MINH, Math.ceil(c.offsetHeight / GRID) * GRID);
// колонки дат: линия и подкраска на всю высоту доски (высота ставится в relayout), подписи — в липкой полосе над доской;
// прошедшие даты подкрашены, сегодняшняя выделена
function renderDateCols(dates, layerX, colW, nodes) {
const today = new Date().toISOString().slice(0, 10), track = $("datesTrack"); track.innerHTML = "";
dates.forEach((d, c) => {
const el = document.createElement("div"), count = nodes.filter(n => (n.due || "") === d).length, x0 = layerX[c] - LEFT / 2, w = colW(c);
const cls = d ? (d < today ? " past" : d === today ? " today" : "") : " none";
el.className = "dcol" + cls; el.style.left = x0 + "px"; el.style.width = w + "px";
board.appendChild(el); dateEls.push(el);
const lb = document.createElement("span"); lb.className = "dlabel" + cls; lb.style.left = (x0 + 8) + "px";
lb.title = d ? (d < today ? "срок прошёл" : d === today ? "сегодня" : "плановая дата окончания") : "плановая дата не задана";
lb.innerHTML = `${d ? fmtDate(d) : "без срока"}<span class="n">${count}</span>`; track.appendChild(lb);
// ручка правой границы: тянуть — менять ширину колонки, двойной щелчок — вернуть автоматическую
const sp = document.createElement("div"); sp.className = "dsplit"; sp.style.left = (x0 + w) + "px"; sp.title = "Тянуть — ширина колонки; двойной щелчок — автоматическая";
sp.addEventListener("pointerdown", ev => startColResize(ev, d, w, sp));
sp.addEventListener("dblclick", () => { const cw = colWidths(); delete cw[d]; store.set(fileKey("colw"), JSON.stringify(cw)); render(); });
track.appendChild(sp);
});
}
// перетаскивание границы колонки: слушатели на window, потому что доска перерисовывается на каждом кадре и ручка пересоздаётся
function startColResize(ev, date, w0, handle) {
ev.preventDefault(); handle.classList.add("on"); $("datesIn").classList.add("resizing");
const x0 = ev.clientX; let raf = 0, last = w0;
const apply = w => { const cw = colWidths(); if (w <= COLW) delete cw[date]; else cw[date] = Math.round(w / GRID) * GRID; store.set(fileKey("colw"), JSON.stringify(cw)); };
const move = e => { last = Math.max(COLW, w0 + e.clientX - x0); if (!raf) raf = requestAnimationFrame(() => { raf = 0; apply(last); render(); }); };
const stop = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop); $("datesIn").classList.remove("resizing"); apply(last); render(); };
window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop);
}
// полоса дат: ширина как у доски, сдвиг как у горизонтальной прокрутки доски; липкая полоса прокрутки внизу окна повторяет прокрутку доски и управляет ею
const wrapEl = $("wrap"), hscrollEl = $("hscroll");
function syncDates() {
$("datesIn").style.width = wrapEl.offsetWidth + "px"; $("datesTrack").style.transform = `translateX(${-wrapEl.scrollLeft}px)`;
hscrollEl.hidden = wrapEl.scrollWidth <= wrapEl.clientWidth + 1;
hscrollEl.style.width = wrapEl.offsetWidth + "px"; $("hscrollIn").style.width = wrapEl.scrollWidth + "px";
if (Math.abs(hscrollEl.scrollLeft - wrapEl.scrollLeft) > 1) hscrollEl.scrollLeft = wrapEl.scrollLeft;
}
wrapEl.addEventListener("scroll", syncDates);
hscrollEl.addEventListener("scroll", () => { if (Math.abs(hscrollEl.scrollLeft - wrapEl.scrollLeft) > 1) wrapEl.scrollLeft = hscrollEl.scrollLeft; });
if (window.ResizeObserver) new ResizeObserver(syncDates).observe(wrapEl);
function renderLane(g, li, count) {
const d = document.createElement("div"); d.className = "lane";
d.innerHTML = `<div class="lane-head"><button type="button" class="fold" title="Свернуть или развернуть дорожку"></button><span class="lane-name" title="Перетащить, чтобы поменять дорожки местами">${esc(g.name)}</span><span class="lane-count">${plural(count, "задача", "задачи", "задач")}</span><span class="lane-tools"><button type="button" title="Выше" ${li === 0 ? "disabled" : ""}>▲</button><button type="button" title="Ниже" ${li === lanesGeom.length - 1 ? "disabled" : ""}>▼</button></span></div>`;
board.appendChild(d); g.el = d;
d.querySelector(".fold").addEventListener("click", () => toggleLane(g.name));
const [up, down] = d.querySelectorAll(".lane-tools button");
up.addEventListener("click", () => moveLane(g.name, li - 1));
down.addEventListener("click", () => moveLane(g.name, li + 1));
// перетаскивание дорожки за название: подсветка целевой границы, перестановка при отпускании
const nameEl = d.querySelector(".lane-name");
nameEl.addEventListener("pointerdown", ev => {
ev.preventDefault(); nameEl.setPointerCapture(ev.pointerId); d.classList.add("dragging");
let target = li;
const where = e => { const y = e.clientY - board.getBoundingClientRect().top; const i = lanesGeom.findIndex(g => y < g.y + g.h / 2); return i < 0 ? lanesGeom.length - 1 : i; };
const move = e => { target = where(e); lanesGeom.forEach((x, i) => { x.el.classList.toggle("target", i === target && i < li); x.el.classList.toggle("target-after", i === target && i > li); }); };
const stop = () => { nameEl.removeEventListener("pointermove", move); nameEl.removeEventListener("pointerup", stop); nameEl.removeEventListener("pointercancel", stop); d.classList.remove("dragging"); lanesGeom.forEach(x => x.el.classList.remove("target", "target-after")); if (target !== li) moveLane(g.name, target); };
nameEl.addEventListener("pointermove", move); nameEl.addEventListener("pointerup", stop); nameEl.addEventListener("pointercancel", stop);
});
}
function renderCard(n, multiSprint) {
const c = document.createElement("div");
c.className = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : ""); c.title = n.title;
const due = n.due ? `<span class="due">до ${fmtDate(n.due)}</span>` : "";
const state = n.status || n.due ? `<div class="state">${n.status ? `<span class="status" title="${esc(n.status)}">${esc(n.status)}</span>` : ""}${due}</div>` : "";
const tag = n.stub || !(multiSprint || n.dim) ? "" : n.sprint
? `<span class="sprint-tag${S.sprints.has(n.sprint) ? " sel" : ""}" title="${esc(n.sprint)}">${esc(n.sprint.replace(/^AWPCA_/, ""))}</span>`
: `<span class="sprint-tag none">без спринта</span>`;
const who = n.stub ? "" : `<div class="who${unassigned(n.who) ? " none" : ""}" title="${esc(n.who)}">${esc(shortName(n.who))}</div>`;
c.innerHTML = `<div class="head"><a class="key" href="${JIRA}/browse/${n.key}" target="_blank" rel="noopener">${n.key}</a>${tag}</div>
<div class="title">${esc(n.title)}</div>
<div class="meta"><span class="kind"><i style="background:var(--k-${KIND[n.kind] || "org"})"></i>${esc(n.kind)}</span><span class="prio" style="color:var(--p-${PRIO[n.prio] || "mid"})">${esc(n.prio)}</span></div>
${state}${who}
<div class="rsz" title="Потянуть — изменить размер; двойной щелчок — размер по содержимому"></div><div class="grip t" title="Тянуть за рамку"></div><div class="grip b"></div><div class="grip l"></div><div class="grip r"></div>`;
cardEls[n.key] = c; board.appendChild(c);
c.addEventListener("pointerdown", ev => startDrag(ev, n.key, c));
c.querySelector(".rsz").addEventListener("dblclick", () => { const cc = clampToCol(n.key, rel[n.key].x, CW); rel[n.key] = { ...rel[n.key], x: cc.x, w: cc.w, auto: true }; rel[n.key].h = autoHeight(n.key); relayout(); drawEdges(); saveLayout(); });
}
// перетаскивание за рамку или изменение размера за уголок: направляющие по соседям, привязка к сетке, карточка не выше своей дорожки
function startDrag(ev, key, c) {
if (ev.target.closest("a")) return;
const resizing = !!ev.target.closest(".rsz");
if (!resizing && !ev.target.closest(".grip")) return; // остальное — текст, его можно выделять
ev.preventDefault();
const q0 = { ...pos[key] }, start = { x: ev.clientX, y: ev.clientY };
c.classList.add("drag"); c.setPointerCapture(ev.pointerId);
const move = e => {
const dx = e.clientX - start.x, dy = e.clientY - start.y;
if (resizing) {
let { w, h } = alignEdges(key, { x: q0.x, y: q0.y, w: Math.max(MINW, q0.w + dx), h: Math.max(MINH, q0.h + dy) }, true);
if (S.snap) { w = Math.round(w / GRID) * GRID; h = Math.round(h / GRID) * GRID; }
const cc = clampToCol(key, rel[key].x, Math.max(MINW, w));
rel[key] = { ...rel[key], w: cc.w, h: Math.max(MINH, h), auto: false };
} else {
const g = alignEdges(key, { x: Math.max(0, q0.x + dx), y: Math.max(0, q0.y + dy), w: q0.w, h: q0.h }, false);
let { x, y } = g;
if (S.snap && !g.snappedX) x = Math.round(x / GRID) * GRID;
if (S.snap && !g.snappedY) y = Math.round(y / GRID) * GRID;
rel[key] = { ...rel[key], x: clampToCol(key, Math.max(0, x), rel[key].w).x, ry: Math.max(TOP, y - lanesGeom[laneOf[key]].y) };
}
relayout(); drawEdges();
};
const stop = () => { c.classList.remove("drag"); c.removeEventListener("pointermove", move); c.removeEventListener("pointerup", stop); c.removeEventListener("pointercancel", stop); guidesSvg.innerHTML = ""; relayout(); saveLayout(); };
c.addEventListener("pointermove", move); c.addEventListener("pointerup", stop); c.addEventListener("pointercancel", stop);
}
function autoHeight(k) {
const c = cardEls[k]; if (!c) return rel[k].h;
c.style.height = "auto"; c.querySelector(".title").style.webkitLineClamp = "unset";
return measuredHeight(c);
}
// координаты дорожек и карточек по rel: дорожка растёт под карточки, свёрнутая — только шапка, её карточки спрятаны и в pos не попадают
function relayout() {
const folded = foldedSet(); let yy = 0, w = baseW; pos = {};
lanesGeom.forEach((g, li) => {
g.folded = folded.has(g.name);
let h = g.folded ? FOLD_H : g.minH;
if (!g.folded) Object.entries(rel).forEach(([k, r]) => { if (laneOf[k] === li) { h = Math.max(h, r.ry + r.h + 12); w = Math.max(w, r.x + r.w + LEFT); } });
g.y = yy; g.h = h; yy += h;
g.el.style.top = g.y + "px"; g.el.style.height = g.h + "px"; g.el.classList.toggle("folded", g.folded);
});
Object.entries(rel).forEach(([k, r]) => {
const el = cardEls[k], g = lanesGeom[laneOf[k]];
if (g.folded) { if (el) el.hidden = true; return; }
pos[k] = { x: r.x, y: g.y + r.ry, w: r.w, h: r.h };
if (!el) return;
el.hidden = false; el.style.left = pos[k].x + "px"; el.style.top = pos[k].y + "px"; el.style.width = r.w + "px"; el.style.height = r.h + "px";
el.querySelector(".title").style.webkitLineClamp = r.auto ? "unset" : String(Math.max(1, Math.floor((r.h - (r.other || 112)) / TITLE_LH)));
});
board.style.width = w + "px"; board.style.height = yy + "px";
dateEls.forEach(el => { el.style.height = yy + "px"; });
if (S.byDate) $("datesTrack").style.width = w + "px";
syncDates();
}
function toggleLane(name) { const s = foldedSet(); if (s.has(name)) s.delete(name); else s.add(name); store.set(fileKey("fold"), JSON.stringify([...s])); relayout(); drawEdges(); }
function moveLane(name, to) {
const names = VIS.lanes.map(l => l.name), from = names.indexOf(name);
if (from < 0 || to < 0 || to >= names.length || from === to) return;
names.splice(to, 0, names.splice(from, 1)[0]);
store.set(fileKey("lanes"), JSON.stringify(names.concat(laneOrder().filter(n => !names.includes(n)))));
render();
}
// стрелка от блокера к зависимой — ломаная из горизонтальных и вертикальных отрезков со скруглёнными углами: выходит из карточки и входит
// в карточку перпендикулярно грани. Карточки разнесены по горизонтали — через середину промежутка; иначе по вертикали через середину промежутка;
// карточки перекрываются и по вертикали — обход справа от обеих
const EDGE_R = 10;
function orthoPath(pts) {
let d = `M${pts[0].x} ${pts[0].y}`;
for (let i = 1; i < pts.length - 1; i++) {
const A = pts[i - 1], P = pts[i], B = pts[i + 1];
const l1 = Math.hypot(P.x - A.x, P.y - A.y), l2 = Math.hypot(B.x - P.x, B.y - P.y), r = Math.min(EDGE_R, l1 / 2, l2 / 2);
if (r < 1) { d += ` L${P.x} ${P.y}`; continue; }
const u1 = { x: (P.x - A.x) / l1, y: (P.y - A.y) / l1 }, u2 = { x: (B.x - P.x) / l2, y: (B.y - P.y) / l2 };
d += ` L${P.x - u1.x * r} ${P.y - u1.y * r} Q${P.x} ${P.y} ${P.x + u2.x * r} ${P.y + u2.y * r}`;
}
const E = pts[pts.length - 1]; return d + ` L${E.x} ${E.y}`;
}
function edgePath(a, b) {
if (b.x >= a.x + a.w + 20) { const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2, xm = Math.round((x1 + x2) / 2); return orthoPath(y1 === y2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: xm, y: y1 }, { x: xm, y: y2 }, { x: x2, y: y2 }]); }
if (a.x >= b.x + b.w + 20) { const x1 = a.x, y1 = a.y + a.h / 2, x2 = b.x + b.w, y2 = b.y + b.h / 2, xm = Math.round((x1 + x2) / 2); return orthoPath(y1 === y2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: xm, y: y1 }, { x: xm, y: y2 }, { x: x2, y: y2 }]); }
const overlapY = b.y < a.y + a.h + 20 && b.y + b.h + 20 > a.y;
if (overlapY) { const y1 = a.y + a.h / 2, y2 = b.y + b.h / 2, xr = Math.max(a.x + a.w, b.x + b.w) + 28; return orthoPath([{ x: a.x + a.w, y: y1 }, { x: xr, y: y1 }, { x: xr, y: y2 }, { x: b.x + b.w, y: y2 }]); }
const down = b.y >= a.y, x1 = a.x + a.w / 2, y1 = down ? a.y + a.h : a.y, x2 = b.x + b.w / 2, y2 = down ? b.y : b.y + b.h, ym = Math.round((y1 + y2) / 2);
return orthoPath(x1 === x2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: x1, y: ym }, { x: x2, y: ym }, { x: x2, y: y2 }]);
}
function drawEdges() {
edgesSvg.innerHTML = ARROWS;
VIS.links.forEach(l => {
const a = pos[l.src], b = pos[l.dst]; if (!a || !b) return;
const related = l.type === RELATED, p = document.createElementNS(NS, "path");
p.setAttribute("d", edgePath(a, b)); p.setAttribute("fill", "none");
p.setAttribute("stroke", related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)");
p.setAttribute("stroke-width", l.crit ? "4" : related ? "1.4" : "1.8");
if (related) p.setAttribute("stroke-dasharray", "5 4"); else p.setAttribute("marker-end", l.hand ? "url(#arrHand)" : "url(#arr)");
edgesSvg.appendChild(p);
});
}
// примагничивание к краям и центрам соседей в пределах SNAP px; рисует направляющие
function alignEdges(key, r, resizing) {
const xs = [], ys = [];
Object.entries(pos).forEach(([k, q]) => { if (k !== key) { xs.push(q.x, q.x + q.w / 2, q.x + q.w); ys.push(q.y, q.y + q.h / 2, q.y + q.h); } });
const mine = resizing ? { x: [r.x + r.w], y: [r.y + r.h] } : { x: [r.x, r.x + r.w / 2, r.x + r.w], y: [r.y, r.y + r.h / 2, r.y + r.h] };
const nearest = (vals, targets) => { let best = null; vals.forEach(v => targets.forEach(t => { const d = t - v; if (Math.abs(d) <= SNAP && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, t }; })); return best; };
const bx = nearest(mine.x, xs), by = nearest(mine.y, ys);
const out = { x: r.x, y: r.y, w: r.w, h: r.h, snappedX: !!bx, snappedY: !!by };
if (bx) { if (resizing) out.w += bx.d; else out.x += bx.d; }
if (by) { if (resizing) out.h += by.d; else out.y += by.d; }
guidesSvg.innerHTML = "";
const line = (x1, y1, x2, y2) => { const l = document.createElementNS(NS, "line"); l.setAttribute("x1", x1); l.setAttribute("y1", y1); l.setAttribute("x2", x2); l.setAttribute("y2", y2); guidesSvg.appendChild(l); };
if (bx) line(bx.t, 0, bx.t, board.offsetHeight);
if (by) line(0, by.t, board.offsetWidth, by.t);
return out;
}
// ===== 6. Панель управления: статистика, панель проблем, выпадающие списки, легенда, меню
const setToggle = (id, on) => $(id).setAttribute("aria-pressed", String(on));
const refresh = () => { updateFacets(); render(); renderStatus(); };
function renderStatus() {
const m = MODEL, v = VIS, r = v.removed;
const coreShown = v.nodes.filter(n => !n.dim).length, ctxShown = v.nodes.length - coreShown;
const parts = [`<b>${coreShown}</b> задач из ${m.nodes.filter(n => !n.stub).length}`];
if (ctxShown) parts.push(`<b>+${ctxShown}</b> соседи по связям`);
parts.push(`<b>${v.links.length}</b> связей из ${m.links.length}`);
if (r.loose) parts.push(`скрыто без связей: ${r.loose}`);
$("stats").innerHTML = parts.join(" · ");
const notes = m.problems.map(esc);
if (m.missing) notes.push(`упомянуты в связях, но в файле отсутствуют: ${m.missing}`);
$("notice").hidden = !notes.length; $("notice").innerHTML = notes.map(n => `<span>${n}</span>`).join("");
}
function renderProblems() {
const pr = VIS.problems || { dates: [], unassigned: [], late: [], hand: [] };
const total = pr.dates.length + pr.unassigned.length + pr.late.length;
$("probCount").textContent = String(total); $("probCount").classList.toggle("zero", !total);
const link = k => `<a class="k" href="${JIRA}/browse/${k}" target="_blank" rel="noopener">${k}</a>`;
const sec = (title, list) => list.length ? `<h4>${title} (${list.length})</h4><ul>${list.map(x => `<li>${link(x.a.key)} → ${link(x.b.key)} <span class="why">— ${esc(x.text)}</span></li>`).join("")}</ul>` : "";
$("probBody").innerHTML = (total ? "" : `<div class="ok">По датам, исполнителям и статусам блокеров на видимой части доски проблем нет.</div>`)
+ sec("Срок блокера не раньше срока зависимой задачи", pr.dates) + sec("Блокер без исполнителя", pr.unassigned)
+ sec("Блокер не начат, а срок зависимой задачи близко", pr.late) + sec("Передачи между исполнителями (точки ожидания)", pr.hand);
}
function renderTable() {
$("tblBody").innerHTML = VIS.links.map(l => `<tr><td class="k">${l.src}</td><td>${esc(l.type)}</td><td class="k">${l.dst}</td></tr>`).join("");
}
const showProblems = on => { $("problems").hidden = !on; setToggle("probBtn", on); store.set(pref("prob"), on ? "1" : "0"); };
// общее поведение выпадающих списков: кнопка открывает, щелчок вне или Escape закрывает
function dropdown(dd, btn, menu, onOpen) {
const open = () => { menu.hidden = false; btn.setAttribute("aria-expanded", "true"); if (onOpen) onOpen(); };
const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); };
btn.addEventListener("click", () => (menu.hidden ? open() : close()));
document.addEventListener("pointerdown", e => { if (!dd.contains(e.target)) close(); });
dd.addEventListener("keydown", e => { if (e.key === "Escape") { close(); btn.focus(); } });
return { open, close };
}
const ddShell = (box, inner) => {
box.innerHTML = ""; const dd = document.createElement("div"); dd.className = "dd";
dd.innerHTML = `<button type="button" class="btn dd-btn" aria-haspopup="listbox" aria-expanded="false"><span class="lbl"></span><span class="car">▼</span></button><div class="dd-menu" hidden>${inner}</div>`;
box.appendChild(dd);
return { dd, btn: dd.querySelector(".dd-btn"), menu: dd.querySelector(".dd-menu"), list: dd.querySelector(".dd-list") };
};
const ddItem = (cls, inputHtml, label, extra = "") => { const row = document.createElement("label"); row.className = "dd-item" + (cls ? " " + cls : ""); row.innerHTML = `${inputHtml}<span>${esc(label)}</span>${extra}`; return row; };
// мультиселект с поиском: пустой выбор = без фильтра
function makeMulti(box, { items, selected, allLabel, onChange }) {
box.innerHTML = "";
const { dd, btn, menu, list } = ddShell(box, `<input type="search" placeholder="Поиск…" aria-label="Поиск"><div class="dd-list" role="listbox"></div><div class="dd-foot"><button type="button" class="btn all">Выбрать все</button><button type="button" class="btn clear">Снять выбор</button></div>`);
const search = dd.querySelector("input[type=search]");
const label = () => {
const sel = items.filter(i => selected.has(i.value));
btn.querySelector(".lbl").textContent = !sel.length ? allLabel : sel.length === 1 ? sel[0].label : `${sel[0].label} +${sel.length - 1}`;
btn.title = sel.map(i => i.label).join(", ");
};
const draw = () => {
const q = search.value.trim().toLowerCase(); list.innerHTML = "";
const shown = items.filter(i => !q || i.label.toLowerCase().includes(q) || (i.value || "").toLowerCase().includes(q));
if (!shown.length) { list.innerHTML = `<div class="dd-empty">ничего не найдено</div>`; return; }
shown.forEach(i => {
const row = ddItem(i.cls, `<input type="checkbox" ${selected.has(i.value) ? "checked" : ""}>`, i.label, `<span class="n">${i.count}</span>`); row.title = i.title || i.label;
row.querySelector("input").addEventListener("change", e => { if (e.target.checked) selected.add(i.value); else selected.delete(i.value); label(); onChange(); });
list.appendChild(row);
});
};
dropdown(dd, btn, menu, () => { search.value = ""; draw(); search.focus(); });
search.addEventListener("input", draw);
dd.querySelector(".all").addEventListener("click", () => { items.forEach(i => selected.add(i.value)); draw(); label(); onChange(); });
dd.querySelector(".clear").addEventListener("click", () => { selected.clear(); draw(); label(); onChange(); });
label();
return { update(next) { items = next; label(); if (!menu.hidden) draw(); } };
}
// счётчики в выпадашках: «n/всего» — n задач с этим значением проходят два других фильтра задач, всего — в файле.
// Порядок пунктов — по n, затем по всему, затем по имени
function facetItems(field, skip, labelOf, cls) {
const total = {}, shown = {};
MODEL.nodes.filter(n => !n.stub).forEach(n => { const k = field(n); total[k] = (total[k] || 0) + 1; if (passes(n, skip)) shown[k] = (shown[k] || 0) + 1; });
const keys = Object.keys(total).sort((a, b) => (a === "") - (b === "") || (shown[b] || 0) - (shown[a] || 0) || total[b] - total[a] || a.localeCompare(b, "ru"));
return { total, items: keys.map(k => ({ value: k, label: labelOf(k), title: `${labelOf(k)}: ${shown[k] || 0} из ${total[k]} задач проходят остальные фильтры`, count: `${shown[k] || 0}/${total[k]}`, cls: cls(k) + (shown[k] ? "" : " zero") })) };
}
let sprintDd = null, whoDd = null;
const sprintItems = () => facetItems(n => n.sprint || "", "sprint", k => k || "без спринта", k => (k ? "" : "none"));
const whoItems = () => facetItems(n => whoKey(n.who), "who", k => (k ? shortName(k) : "не назначен"), k => (k ? "" : "none"));
// список спринтов: по умолчанию выбран самый наполненный; «без спринта» — отдельный пункт; выбор помнится; пустой выбор = все спринты
function fillSprints() {
const box = $("sprint");
if (!MODEL.hasSprint) { box.innerHTML = `<span class="file-name">колонки «Спринт» нет — показаны все задачи</span>`; S.sprints = new Set(); sprintDd = null; return; }
const { total, items } = sprintItems();
const remembered = store.json(fileKey("sprint"), null);
S.sprints = new Set(strList(remembered).filter(n => total[n] !== undefined));
if (!S.sprints.size && !(Array.isArray(remembered) && remembered.length === 0)) { // пустой сохранённый выбор — осознанное «все спринты»
const top = Object.entries(total).filter(([n]) => n).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
if (top) S.sprints = new Set([top[0]]);
}
sprintDd = makeMulti(box, { items, selected: S.sprints, allLabel: "Все спринты", onChange: () => { store.set(fileKey("sprint"), JSON.stringify([...S.sprints])); refresh(); } });
}
// список исполнителей: чистый фильтр, на доске только их задачи; соседей добавляет кнопка «+ соседи по связям»
function fillWho() {
const box = $("who"), { total, items } = whoItems();
const names = Object.keys(total);
if (!names.length || (names.length === 1 && names[0] === "")) { box.innerHTML = `<span class="file-name">${names.length ? "все задачи без исполнителя" : "колонки «Исполнитель» нет"}</span>`; S.who = new Set(); whoDd = null; return; }
S.who = new Set(strList(store.json(fileKey("who"), [])).filter(w => total[w] !== undefined));
whoDd = makeMulti(box, { items, selected: S.who, allLabel: "Все исполнители", onChange: () => { store.set(fileKey("who"), JSON.stringify([...S.who])); refresh(); } });
}
const updateFacets = () => { if (sprintDd) sprintDd.update(sprintItems().items); if (whoDd) whoDd.update(whoItems().items); };
// переключатели-кнопки: значение, ключ хранилища, id кнопки
const flag = (name, key, id) => { S[name] = !S[name]; store.set(pref(key), S[name] ? "1" : "0"); setToggle(id, S[name]); };
$("neighbors").addEventListener("click", () => { flag("neighbors", "neighbors", "neighbors"); refresh(); });
$("onlyLinked").addEventListener("click", () => { flag("onlyLinked", "onlylinked", "onlyLinked"); refresh(); });
$("onlyHand").addEventListener("click", () => { flag("onlyHand", "onlyhand", "onlyHand"); refresh(); });
$("critPath").addEventListener("click", () => { flag("crit", "crit", "critPath"); refresh(); });
$("byDate").addEventListener("click", () => { flag("byDate", "bydate", "byDate"); render(); });
$("snap").addEventListener("click", () => flag("snap", "snap", "snap"));
$("probBtn").addEventListener("click", () => showProblems($("problems").hidden));
$("probClose").addEventListener("click", () => showProblems(false));
// легенда-фильтр: data-kind скрывает карточки вида работы, data-edge — стрелки типа связи
function drawLegend() {
document.querySelectorAll("#legend .lg").forEach(b => {
const off = b.dataset.kind ? S.hideKind.has(b.dataset.kind) : S.hideEdge.has(b.dataset.edge);
b.classList.toggle("off", off); b.setAttribute("aria-pressed", String(!off)); b.title = off ? "Показать снова" : "Скрыть на доске";
});
}
document.querySelectorAll("#legend .lg").forEach(b => b.addEventListener("click", () => {
const set = b.dataset.kind ? S.hideKind : S.hideEdge, v = b.dataset.kind || b.dataset.edge;
if (set.has(v)) set.delete(v); else set.add(v);
store.set(pref("hidekind"), JSON.stringify([...S.hideKind])); store.set(pref("hideedge"), JSON.stringify([...S.hideEdge]));
drawLegend(); refresh();
}));
// меню «⋯»
const more = $("more"), moreDd = dropdown(more, more.querySelector(".more-btn"), more.querySelector(".menu"));
$("tblToggle").addEventListener("click", () => { const on = $("tblBox").hidden; $("tblBox").hidden = !on; $("tblBox").open = on; setToggle("tblToggle", on); store.set(pref("tbl"), on ? "1" : "0"); });
const THEMES = ["", "light", "dark"], THEME_NAMES = { "": "системная", light: "светлая", dark: "тёмная" };
let theme = store.get(pref("theme"), ""); if (!THEMES.includes(theme)) theme = "";
const applyTheme = t => { if (t) document.documentElement.setAttribute("data-theme", t); else document.documentElement.removeAttribute("data-theme"); $("theme").textContent = "Тема: " + THEME_NAMES[t]; };
$("theme").addEventListener("click", () => { theme = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]; store.set(pref("theme"), theme); applyTheme(theme); });
// сброс раскладки с подтверждением прямо в меню (диалоги браузера в просмотрщике артефактов не показываются)
$("reset").addEventListener("click", () => {
const wrap = $("resetWrap"), btn = $("reset");
if (wrap.querySelector(".confirm")) return;
const c = document.createElement("div"); c.className = "confirm";
c.innerHTML = `<span>Вернуть автоматическую раскладку ${S.byDate ? "режима «по датам»" : "(обычную; раскладка «по датам» останется)"}, порядок и раскрытие дорожек?</span><div class="row"><button type="button" class="btn yes">Да, сбросить</button><button type="button" class="btn no">Нет</button></div>`;
btn.hidden = true; wrap.appendChild(c);
const close = () => { c.remove(); btn.hidden = false; };
c.querySelector(".yes").addEventListener("click", () => { [posKey(), "lanes", "fold"].concat(S.byDate ? ["colw"] : []).forEach(k => store.del(fileKey(k))); close(); moreDd.close(); render(); });
c.querySelector(".no").addEventListener("click", close);
setTimeout(() => { if (c.isConnected) close(); }, 8000);
});
// высота закреплённой шапки — панель проблем липнет под ней
const headEl = document.querySelector(".sticky");
const setHeadH = () => document.documentElement.style.setProperty("--head-h", (getComputedStyle(headEl).position === "sticky" ? headEl.offsetHeight : 0) + "px");
if (window.ResizeObserver) new ResizeObserver(setHeadH).observe(headEl); window.addEventListener("resize", setHeadH);
// ===== 7. Загрузка, экспорт, запуск
function loadCsv(text, fileName) {
MODEL = fromJiraCsv(text, fileName);
$("fileName").textContent = fileName; $("fileName").title = fileName;
fillSprints(); fillWho(); refresh();
}
// импорт JSON: раскладка и фильтры кладутся в хранилище доски, так они применятся и к следующей выгрузке CSV
function loadJson(text, fileName) {
const r = fromJson(text, fileName); MODEL = r.model;
if (Object.keys(r.layout).length) store.set(fileKey("pos"), JSON.stringify(r.layout));
if (Object.keys(r.layoutByDate).length) store.set(fileKey("posdate"), JSON.stringify(r.layoutByDate));
if (r.laneOrder.length) store.set(fileKey("lanes"), JSON.stringify(r.laneOrder));
if (r.collapsedLanes) store.set(fileKey("fold"), JSON.stringify(r.collapsedLanes));
if (r.dateColumns) store.set(fileKey("colw"), JSON.stringify(r.dateColumns));
const f = r.filters;
if (Array.isArray(f.sprints)) store.set(fileKey("sprint"), JSON.stringify(strList(f.sprints)));
if (Array.isArray(f.assignees)) store.set(fileKey("who"), JSON.stringify(strList(f.assignees)));
if (f.mode === "linked") f.neighbors = true; // старые выгрузки: режим «спринт и связанные» = соседи, «все задачи файла» = пустой выбор спринта
if (f.mode === "all") store.set(fileKey("sprint"), "[]");
if (typeof f.neighbors === "boolean") { S.neighbors = f.neighbors; store.set(pref("neighbors"), f.neighbors ? "1" : "0"); setToggle("neighbors", f.neighbors); }
if (typeof f.onlyLinked === "boolean") { S.onlyLinked = f.onlyLinked; store.set(pref("onlylinked"), f.onlyLinked ? "1" : "0"); setToggle("onlyLinked", f.onlyLinked); }
if (typeof f.onlyHand === "boolean") { S.onlyHand = f.onlyHand; store.set(pref("onlyhand"), f.onlyHand ? "1" : "0"); setToggle("onlyHand", f.onlyHand); }
if (typeof f.criticalPath === "boolean") { S.crit = f.criticalPath; store.set(pref("crit"), f.criticalPath ? "1" : "0"); setToggle("critPath", f.criticalPath); }
if (typeof f.byDate === "boolean") { S.byDate = f.byDate; store.set(pref("bydate"), f.byDate ? "1" : "0"); setToggle("byDate", f.byDate); }
if (Array.isArray(f.hiddenKinds)) { S.hideKind = new Set(strList(f.hiddenKinds)); store.set(pref("hidekind"), JSON.stringify([...S.hideKind])); }
if (Array.isArray(f.hiddenEdges)) { S.hideEdge = new Set(strList(f.hiddenEdges)); store.set(pref("hideedge"), JSON.stringify([...S.hideEdge])); }
drawLegend();
$("fileName").textContent = `${MODEL.fileName} (из JSON)`; $("fileName").title = `${MODEL.fileName} — загружено из ${fileName}`;
fillSprints(); fillWho(); refresh();
}
$("file").addEventListener("change", ev => {
const f = ev.target.files && ev.target.files[0]; if (!f) return;
const rd = new FileReader();
rd.onload = () => {
const text = String(rd.result), isJson = /\.json$/i.test(f.name) || /^?\s*\{/.test(text);
try { if (isJson) loadJson(text, f.name); else loadCsv(text, f.name); }
catch (e) { $("stats").innerHTML = `<span class="warn">Не удалось разобрать ${esc(f.name)}: ${esc(e.message)}</span>`; }
};
rd.readAsText(f, "utf-8");
ev.target.value = ""; // чтобы тот же файл можно было выбрать повторно
});
// полный снимок доски: все задачи файла и связи, обе раскладки относительно дорожек (layout — обычная, layoutByDate — «по датам»; h только если задана вручную),
// порядок и свёртка дорожек, фильтры. Текущая раскладка берётся с доски (rel), другая — из хранилища
function exportJson() {
saveLayout();
const savedPos = store.json(fileKey("pos"), {}) || {}, savedDate = store.json(fileKey("posdate"), {}) || {}, vis = new Set(VIS.nodes.map(n => n.key));
const fromSaved = (saved, k) => { const r = saved[k]; if (!r) return null; const o = { x: r.x, ry: r.ry, w: r.w }; if (typeof r.h === "number") o.h = r.h; return o; };
const layout = k => fromSaved(savedPos, k), layoutByDate = k => fromSaved(savedDate, k);
const nodes = MODEL.nodes.filter(n => !n.stub).map(n => ({ key: n.key, title: n.title, status: n.status, priority: n.prio, kind: n.kind, due: n.due || null, assignee: n.who || null, sprint: n.sprint || null, epic: n.epic,
url: `${JIRA}/browse/${n.key}`, visible: vis.has(n.key), layout: layout(n.key), layoutByDate: layoutByDate(n.key) }));
const order = laneOrder();
return { format: "arm-ka-links/1", exportedAt: new Date().toISOString(), source: MODEL.fileName,
filters: { sprints: [...S.sprints], assignees: [...S.who], neighbors: S.neighbors, onlyLinked: S.onlyLinked, onlyHand: S.onlyHand, criticalPath: S.crit, byDate: S.byDate, hiddenKinds: [...S.hideKind], hiddenEdges: [...S.hideEdge] },
laneOrder: order.length ? order : VIS.lanes.map(l => l.name), collapsedLanes: [...foldedSet()], dateColumns: colWidths(),
nodes, links: MODEL.links.map(l => ({ from: l.src, to: l.dst, type: l.type })) };
}
$("exportJson").addEventListener("click", () => {
moreDd.close();
const text = JSON.stringify(exportJson(), null, 2);
$("jsonText").value = text; $("exportBox").hidden = false;
try { // в обычном браузере файл скачается; в просмотрщике артефактов скачивание не работает — остаётся текст и «Копировать»
const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
a.download = `links-${(MODEL.fileName || "board").replace(/[^\wА-Яа-яЁё.-]+/g, "_").replace(/\.csv$/i, "")}-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
} catch (e) {}
});
$("copyJson").addEventListener("click", () => {
const ta = $("jsonText"), btn = $("copyJson"), fallback = () => { ta.focus(); ta.select(); };
const done = () => { btn.textContent = "Скопировано"; setTimeout(() => { btn.textContent = "Копировать"; }, 1500); };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(ta.value).then(done).catch(fallback); else fallback();
});
$("closeJson").addEventListener("click", () => { $("exportBox").hidden = true; });
// запуск: настройки из хранилища, затем встроенный пример
{ const old = store.get(pref("mode"), null); if (old !== null) { if (old === "linked") store.set(pref("neighbors"), "1"); if (old === "all") store.set(fileKey("sprint"), "[]"); store.del(pref("mode")); } } // до 11.10.2026 был режим показа
S.neighbors = store.get(pref("neighbors"), "0") === "1"; setToggle("neighbors", S.neighbors);
S.onlyLinked = store.get(pref("onlylinked"), "0") === "1"; setToggle("onlyLinked", S.onlyLinked);
S.onlyHand = store.get(pref("onlyhand"), "0") === "1"; setToggle("onlyHand", S.onlyHand);
S.crit = store.get(pref("crit"), "0") === "1"; setToggle("critPath", S.crit);
S.byDate = store.get(pref("bydate"), "0") === "1"; setToggle("byDate", S.byDate);
S.snap = store.get(pref("snap"), "1") !== "0"; setToggle("snap", S.snap);
S.hideKind = new Set(strList(store.json(pref("hidekind"), []))); S.hideEdge = new Set(strList(store.json(pref("hideedge"), []))); drawLegend();
if (store.get(pref("tbl"), "0") === "1") { $("tblBox").hidden = false; $("tblBox").open = true; setToggle("tblToggle", true); }
showProblems(store.get(pref("prob"), "0") === "1");
applyTheme(theme); setHeadH();
migrateStore();
loadCsv(DEFAULT_CSV, DEFAULT_NAME);
</script>