create-pmos 0.142.0 → 0.144.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,8 @@ import { el } from './dom.js';
21
21
  import { c3ic } from './pm-work-view.js';
22
22
  import { button, ensureComponentsCss } from './primitives.js';
23
23
  import { toast } from './c3-toast.js';
24
+ import { notesView, openNote, noteOfEvent, primeNotes } from './c3-notes.js'; // Calendar › Notes (initiative c3-notes-place)
25
+ import { onNotes } from './notes-links.js';
24
26
 
25
27
  const LAYERS = [
26
28
  ['meetings', 'Meetings'],
@@ -63,6 +65,8 @@ const setVar = (node, name, value) => { node.style.setProperty('--cal3-' + name,
63
65
  function state(r) {
64
66
  const st = r.page || (r.page = { on: new Set(LAYERS.map((l) => l[0]).filter((id) => !(r.ctx && r.ctx.overlay && mem.hide.includes(id)))), free: true, tidy: false, sel: null });
65
67
  if (r.sel) { st.sel = { type: 'event', id: r.sel }; r.sel = null; } // a meeting chosen on the Briefing (openDay)
68
+ if (r.pendingMode) { st.mode = r.pendingMode; r.pendingMode = null; } // a note opened before the page drew (the Record chip)
69
+ if (!st.mode) st.mode = 'cal';
66
70
  return st;
67
71
  }
68
72
 
@@ -99,10 +103,21 @@ export function drawCalendarPage(host, r, h) {
99
103
  ensureComponentsCss(); // the Tidy-up buttons are pmc-btn
100
104
  const st = state(r);
101
105
  const phone = r.root.classList.contains('ph');
106
+ const page = (b, ...kids) => el('div', { class: 'cal3' + (r.ctx.overlay ? ' ov' : ''), 'data-cal3': 'page', 'data-cal3-show': st.mode }, r.ctx.deck || null, b, ...kids);
107
+ const redraw0 = () => h.redraw(host, r);
108
+ const modes = r.ctx.overlay ? null : modeSeg(st, redraw0);
109
+ if (st.mode === 'notes' && modes) { // Calendar › Notes: the list and the open note; the zoom and the period have nothing to step here
110
+ return page(el('div', { class: 'calbar' }, el('div', { class: 'lens' }, modes)), notesView({ host, r, h, st, redraw: redraw0 }));
111
+ }
112
+ if (!r.notesOff) { // a meeting's "Open the note" needs the shared notes read; a new note redraws the open detail
113
+ r.notesOff = onNotes(() => { if (r.root.isConnected && st.mode === 'cal' && st.sel) h.redraw(host, r); });
114
+ primeNotes();
115
+ }
102
116
  const bar = h.bar(host, r, phone);
103
- const page = (...kids) => el('div', { class: 'cal3' + (r.ctx.overlay ? ' ov' : ''), 'data-cal3': 'page' }, r.ctx.deck || null, bar, ...kids);
104
- if (r.error) return page(fold('alert', 'The calendar could not be read: ' + r.error, { 'data-cv': 'error', role: 'status' }));
105
- if (!r.data) return page(fold('clock', 'Reading the repository', { 'data-cv': 'loading', role: 'status' }));
117
+ if (modes) { const lens = bar.querySelector('.lens'); if (lens) lens.prepend(modes); }
118
+ const pageBar = (...kids) => page(bar, ...kids);
119
+ if (r.error) return pageBar(fold('alert', 'The calendar could not be read: ' + r.error, { 'data-cv': 'error', role: 'status' }));
120
+ if (!r.data) return pageBar(fold('clock', 'Reading the repository', { 'data-cv': 'loading', role: 'status' }));
106
121
 
107
122
  const d = r.data; const M = model(d, h);
108
123
  const redraw = (focusSel) => { h.redraw(host, r); if (focusSel) { const n = r.root.querySelector(focusSel); if (n) n.focus(); } };
@@ -133,7 +148,22 @@ export function drawCalendarPage(host, r, h) {
133
148
  el('div', { class: 'cal3-wrap' },
134
149
  el('div', { class: 'cal3-main' }, empty, grid),
135
150
  el('div', { class: 'cal3-side' }, detail(ui), st.tidy ? tidyList(ui) : null, st.free ? freeList(ui, range) : null, lanes(ui, range))));
136
- return page(chips(ui, range), body);
151
+ return pageBar(chips(ui, range), body);
152
+ }
153
+
154
+ /* "Open the note": the note recorded for this meeting, on Calendar › Notes; nothing when no note names the meeting */
155
+ function noteLink(ui, e) {
156
+ if (ui.r.ctx.overlay) return null;
157
+ const n = noteOfEvent(e); if (!n) return null;
158
+ return el('div', { class: 'cal3-ctx', 'data-cal3-ctx': 'note' }, el('h4', null, 'Notes'),
159
+ el('a', { href: '#', class: 'cal3-ref', 'data-cal3': 'open-note', 'data-cal3-open-note': n.id, title: 'Open the note: ' + (n.title || 'Untitled note'),
160
+ onClick: (ev) => { ev.preventDefault(); openNote(ui.r, n.id); ui.redraw(); } }, c3ic('notes', 'sm'), 'Open the note'));
161
+ }
162
+
163
+ /* the Calendar | Notes switch, before the zoom (the mockup the PM approved on 2026-10-12) */
164
+ function modeSeg(st, redraw) {
165
+ const b = (id, icon, label) => { const x = button({ variant: 'ghost', attrs: { class: 'cal3-b', 'aria-pressed': String(st.mode === id), 'data-cal3-mode': id, title: label }, onClick: () => { if (st.mode !== id) { st.mode = id; redraw(); } } }); x.replaceChildren(c3ic(icon, 'sm'), el('span', null, label)); return x; };
166
+ return el('div', { class: 'seg', role: 'group', 'aria-label': 'Show', 'data-cal3': 'mode' }, b('cal', 'cal', 'Calendar'), b('notes', 'notes', 'Notes'));
137
167
  }
138
168
 
139
169
  function fold(icon, text, attrs) { return el('div', { class: 'fold', ...(attrs || {}) }, c3ic(icon, 'sm'), text); }
@@ -424,6 +454,7 @@ function meetingDetail(ui, hit) {
424
454
  el('div', { class: 'dim mono cal3-when' }, h.dayLabel(hit.date) + (s === null ? ', all day' : ' · ' + h.hhmm(s) + ' to ' + h.hhmm(f))),
425
455
  el('div', { class: 'chips2', 'data-cal3': 'meeting-chips' }, homeChip('Calendar'), ...peopleOf(e).map((n) => el('span', { class: 'tag' }, n)),
426
456
  e.initiative ? el('span', { class: 'tag' }, 'Initiative: ' + (((ui.d.gantt || []).find((g) => g.id === e.initiative) || {}).title || e.initiative)) : null),
457
+ noteLink(ui, e),
427
458
  ...body,
428
459
  el('p', { class: 'dim cal3-note' }, past ? 'Only what PMOS holds a record for: items created or closed within 48 hours of the meeting. Each line opens its source.' : 'PMOS proposes changes. It never edits your calendar.'),
429
460
  ];
@@ -211,7 +211,7 @@ const openAsks = (S, bid) => S.asks.filter((a) => a.board === bid && a.state ===
211
211
  function rowOut(S, r) {
212
212
  const wf = wfOf(S, r);
213
213
  return { id: r.id, title: r.title, lane: r.lane, stage: r.stage, cat: catIn(wf, r.stage), owner: r.owner || null, anchor: r.anchor, horizon: r.horizon, status: r.status, start: r.start, end: r.end, progress: r.progress,
214
- warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })) };
214
+ warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })), epics: (r.epics || []).slice(), epicsFrom: r.epics ? 'pmos' : null };
215
215
  }
216
216
  function boardOut(S, b) {
217
217
  const list = S.rows.filter((r) => r.board === b.id);
@@ -219,7 +219,7 @@ function boardOut(S, b) {
219
219
  for (const r of list) byCat[catOfRow(S, r)]++;
220
220
  const rel = nextRelease(S, b.id);
221
221
  return { ws: b.ws, id: b.id, name: b.name, full: b.full, domain: b.domain, template: b.template, templateEdited: tplEdited(b), workflow: b.workflow.map((s) => ({ ...s })), self: b.self, tracker: b.tracker, sprints: b.sprints,
222
- build: b.build, buildDeliver: b.build, jira: b.jira, offer: b.offer.slice(), defTpl: b.defTpl, owner: b.owner, health: b.health,
222
+ build: b.build, buildDeliver: b.build, jira: b.jira, jiraProjects: b.build && b.jira ? (b.jiraProjects || [b.jira]).slice() : [], offer: b.offer.slice(), defTpl: b.defTpl, owner: b.owner, health: b.health,
223
223
  counts: { total: list.length, open: list.filter(isOpen).length, byCat, risk: list.filter((r) => r.status === 'at risk').length, late: list.filter((r) => r.status === 'late').length },
224
224
  asks: openAsks(S, b.id), nextRelease: rel ? { id: rel.id, name: rel.name, at: rel.date, date: rel.date, status: rel.status } : null };
225
225
  }
@@ -396,7 +396,11 @@ export function handle(S, method, path, p, body) {
396
396
  const v = {};
397
397
  const bd = body.buildDeliver != null ? body.buildDeliver : body.build;
398
398
  if (bd != null) { if (typeof bd !== 'boolean') return fail(400, 'buildDeliver is true or false'); v.build = bd; }
399
- if (body.jira != null && body.jira !== '') { const j = String(body.jira).trim(); if (!/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(j)) return fail(400, 'jira is a Jira project key: a letter, then letters, digits or _ (at most 20)'); v.jira = j.toUpperCase(); }
399
+ if (body.jira != null && body.jira !== '') {
400
+ const keys = [].concat(body.jira).join(',').split(/[,\s]+/).filter(Boolean);
401
+ if (!keys.length || keys.some((j) => !/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(j))) return fail(400, 'jira is one or more Jira project keys (at most 10), each a letter, then letters, digits or _ (at most 20)');
402
+ v.jiraProjects = [...new Set(keys.map((j) => j.toUpperCase()))]; v.jira = v.jiraProjects[0];
403
+ }
400
404
  const build = 'build' in v ? v.build : b.build;
401
405
  if (body.offer != null) {
402
406
  if (!Array.isArray(body.offer) || !body.offer.length || body.offer.some((k) => !INI_TEMPLATES[k])) return fail(400, 'offer is a list of templates: ' + Object.keys(INI_TEMPLATES).join(', '));
@@ -413,7 +417,8 @@ export function handle(S, method, path, p, body) {
413
417
  Object.assign(b, v);
414
418
  if (!b.build) { b.offer = b.offer.filter((k) => !templateBuilds(k)); if (!b.offer.length) b.offer = ['simple']; if (!b.offer.includes(b.defTpl)) b.defTpl = b.offer[0]; }
415
419
  if (b.build && !b.jira) b.jira = b.prefix;
416
- b.tracker = b.build && b.jira ? 'Jira · ' + b.jira : null; b.sprints = !!(b.build && b.jira);
420
+ if (!b.jiraProjects || !b.jiraProjects.includes(b.jira)) b.jiraProjects = b.jira ? [b.jira] : [];
421
+ b.tracker = b.build && b.jira ? 'Jira · ' + b.jiraProjects.join(', ') : null; b.sprints = !!(b.build && b.jira);
417
422
  S.events.push({ at: at(0, 8), by: 'You', kind: 'board', op: 'settings', board: b.id, ...v });
418
423
  return okb({ board: boardOut(S, b) });
419
424
  }
@@ -441,6 +446,20 @@ export function handle(S, method, path, p, body) {
441
446
  S.events.push({ at: at(0, 8), by: 'You', kind: 'board-workflow', board: b.id, stages: b.workflow, moved: mr });
442
447
  return okb({ board: boardOut(S, b), moved });
443
448
  }
449
+ if (path === '/api/boards/ini-epics') {
450
+ if (!b.build) return fail(409, b.name + ' has Build and deliver off, so it mirrors no Jira');
451
+ const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
452
+ if (!r) return fail(404, 'no initiative ' + (body.id || '') + ' on this board');
453
+ const keys = Array.isArray(body.epics) ? body.epics : [];
454
+ if (keys.length > 20 || keys.some((k) => !/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(String(k)))) return fail(400, 'epics is a list of Jira keys (at most 20), each like IDPF-2685');
455
+ const epics = [...new Set(keys.map((k) => String(k).toUpperCase()))];
456
+ const projects = b.jiraProjects && b.jiraProjects.length ? b.jiraProjects : b.jira ? [b.jira] : [];
457
+ const off = projects.length ? epics.filter((k) => !projects.includes(k.split('-')[0])) : [];
458
+ if (off.length) return fail(400, off.join(', ') + (off.length === 1 ? ' is' : ' are') + ' not in a Jira project of ' + b.name + ' (' + projects.join(', ') + ')');
459
+ r.epics = epics;
460
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'ini-epics', board: b.id, id: r.id, epics });
461
+ return okb({ row: rowOut(S, r) });
462
+ }
444
463
  if (path === '/api/boards/ini-workflow') {
445
464
  if (b.self) return fail(409, 'The PMOS board follows the ladder its records derive.');
446
465
  const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
@@ -14,7 +14,7 @@
14
14
  * Every control is a button; the drag handlers are drag events on the cards and columns (pointer handlers stay on the document). */
15
15
  import { el } from './dom.js';
16
16
  import { registerBoardTab } from './c3-initiatives.js';
17
- import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, plural, trunc, openMenu, mono } from './c3-ini-ui.js';
17
+ import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, jiraLabel, plural, trunc, openMenu, mono } from './c3-ini-ui.js';
18
18
  import { STATUSES, wfOf, tplName, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
19
19
  import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
20
20
 
@@ -67,7 +67,7 @@ function mountItems(tab) {
67
67
  others > 0 ? el('span', { class: 'dim ini-note' }, plural(others, 'more initiative has', 'more initiatives have') + ' no epics or tasks yet') : null),
68
68
  b.build
69
69
  ? el('div', { class: 'chips2', role: 'group', 'aria-label': 'Source filter', 'data-wk': 'srcs' }, lbl('SOURCE'),
70
- ...[['', 'All sources'], ['jira', [icon('jira', 'sm'), 'Jira ' + (b.jira || '')]], ['pmos', 'PMOS tasks']].map(([v, l]) => pill({ 'data-wk-src': v, 'aria-pressed': String(st.src === v), onclick: () => { st.src = v; draw('[data-wk-src="' + v + '"]'); } }, ...[].concat(l))))
70
+ ...[['', 'All sources'], ['jira', [icon('jira', 'sm'), 'Jira ' + jiraLabel(b)]], ['pmos', 'PMOS tasks']].map(([v, l]) => pill({ 'data-wk-src': v, 'aria-pressed': String(st.src === v), onclick: () => { st.src = v; draw('[data-wk-src="' + v + '"]'); } }, ...[].concat(l))))
71
71
  : el('div', { class: 'dim ini-note', 'data-wk': 'no-jira' }, icon('info', 'sm'), ' Build and deliver is off for ' + b.name + ', so there is no Jira here: every item is a PMOS task. ', lk({ onclick: () => ctx.openBoardSettings() }, 'Board settings')),
72
72
  el('div', { class: 'dim ini-note', 'data-wk': 'count' }, plural(n, 'item') + (ne ? ' in ' + plural(ne, 'epic') : '') + (st.ini ? ' · ' + st.ini + ' only' : '') + (st.src ? ' · ' + (st.src === 'jira' ? 'Jira only' : 'PMOS only') : '') + ' · ',
73
73
  any ? lk({ 'data-wk': 'clear', onclick: () => { Object.assign(st, { q: '', ini: '', src: '', kind: '', hideDone: false }); draw(); } }, 'Clear filters') : null, any ? ' · ' : null, 'same filters on Backlog, Board and Timeline'),
@@ -178,7 +178,7 @@ function mountItems(tab) {
178
178
  requestAnimationFrame(() => { if (!dead) tlInit(host); });
179
179
  return [sect({ ic: 'cal', title: 'Timeline', count: rows.length, right: mono('press a row to open its epics') }, node,
180
180
  hidden > 0 ? el('div', { class: 'chips2' }, el('span', { class: 'dim ini-note' }, plural(hidden, 'more Now initiative', 'more Now initiatives') + ' with no epics yet'), pill({ 'data-wk': 'tl-all', onclick: () => { st.tlAll = true; draw(); } }, 'Show all')) : null,
181
- b.build ? note('Release dates come from Jira ' + (b.jira || '') + ' fix versions. PMOS bars drag to reschedule; Jira bars are locked.') : note('Every bar here is a PMOS task: drag it to reschedule.'))];
181
+ b.build ? note('Release dates come from Jira ' + jiraLabel(b) + ' fix versions. PMOS bars drag to reschedule; Jira bars are locked.') : note('Every bar here is a PMOS task: drag it to reschedule.'))];
182
182
  }
183
183
 
184
184
  function draw(focusSel) {
@@ -8,7 +8,7 @@
8
8
  * its id, one with no date says "No date yet". */
9
9
  import { el } from './dom.js';
10
10
  import { registerBoardTab } from './c3-initiatives.js';
11
- import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, plural, day, daysBetween } from './c3-ini-ui.js';
11
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, jiraLabel, plural, day, daysBetween } from './c3-ini-ui.js';
12
12
  import { loadItems } from './c3-ini-work.js';
13
13
 
14
14
  const whenOf = (r) => String(r.release_at || r.deploy_at || r.date || '').slice(0, 10);
@@ -45,7 +45,7 @@ registerBoardTab('releases', async (host, ctx) => {
45
45
  host.replaceChildren(cols(
46
46
  [sect({ ic: 'out', title: 'Unreleased', count: up.length, attrs: { 'data-inb': 'release-next' } }, up.length ? list(up.map((r) => row(r, false))) : empty('No release planned for ' + b.name + '.', { 'data-inb': 'release-none-next' })),
47
47
  past.length ? sect({ ic: 'check', title: 'Released', count: past.length, attrs: { 'data-inb': 'release-past' } }, list(past.map((r) => row(r, true)))) : null],
48
- [card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + (b.jira || '') + ' fix versions, because this board builds and delivers. Release dates are references: their home is the Calendar.'),
48
+ [card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + jiraLabel(b) + ' fix versions, because this board builds and delivers. Release dates are references: their home is the Calendar.'),
49
49
  el('div', { class: 'chips2' }, pbtn('outline', 'Open the Calendar', 'cal', { 'data-inb': 'open-calendar', onclick: () => ctx.showTab('cal') })))]));
50
50
  return () => { dead = true; };
51
51
  });
@@ -7,7 +7,7 @@
7
7
  * the stage-records change; when that route is not on this server yet, the card says so quietly). source 'none' says there is no tracker. */
8
8
  import { el } from './dom.js';
9
9
  import { registerBoardTab } from './c3-initiatives.js';
10
- import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, plural, day } from './c3-ini-ui.js';
10
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, jiraLabel, plural, day } from './c3-ini-ui.js';
11
11
  import { loadItems } from './c3-ini-work.js';
12
12
  import { recordCards } from './c3-ini-records.js';
13
13
 
@@ -22,7 +22,7 @@ registerBoardTab('sprints', async (host, ctx) => {
22
22
  const [r, items] = await Promise.all([ctx.api.get('/api/boards/sprints', { board: b.id }), loadItems(ctx)]);
23
23
  if (dead) return () => {};
24
24
  if (!r || !r.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The sprints could not be read: ' + ((r && r.error) || 'no answer') + '.')); return () => { dead = true; }; }
25
- if (r.source === 'none') { host.replaceChildren(empty('Waiting for the first sync from Jira ' + (b.jira || '') + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
25
+ if (r.source === 'none') { host.replaceChildren(empty('Waiting for the first sync from Jira ' + jiraLabel(b) + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
26
26
  const sprints = r.sprints || [];
27
27
  const active = sprints.find((s) => s.state === 'active');
28
28
  const others = sprints.filter((s) => s !== active);
@@ -32,7 +32,7 @@ registerBoardTab('sprints', async (host, ctx) => {
32
32
  right: [el('span', { class: 'chip2', title: 'Status lives in Jira' }, icon('lock', 'sm'))], attrs: { 'data-inb-issue': i.key } });
33
33
  const t = readTime(r.readAt);
34
34
  const left = [
35
- el('div', { class: 'fold', 'data-inb': 'sprints-source' }, icon('lock', 'sm'), el('b', null, 'Mirrored from Jira, read-only.'), ' ' + (t ? 'Read ' + t + ' from Jira ' + (b.jira || '') + '.' : 'Not read yet.') + ' Change sprints in Jira.'),
35
+ el('div', { class: 'fold', 'data-inb': 'sprints-source' }, icon('lock', 'sm'), el('b', null, 'Mirrored from Jira, read-only.'), ' ' + (t ? 'Read ' + t + ' from Jira ' + jiraLabel(b) + '.' : 'Not read yet.') + ' Change sprints in Jira.'),
36
36
  active ? sect({ ic: 'check', title: active.name + ' items', count: (active.issues || []).length, right: tag('read-only'), attrs: { 'data-inb-sprint': active.name, 'data-inb-state': 'active' } },
37
37
  (active.issues || []).length ? list(active.issues.map(issueRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }))
38
38
  : empty(sprints.length ? 'No sprint is active.' : 'Jira has no sprints for this board.', { 'data-inb': sprints.length ? 'sprints-no-active' : 'sprints-empty' }),
@@ -14,7 +14,7 @@
14
14
  * Every tab has an honest empty state and an error line when its read fails. */
15
15
  import { el } from './dom.js';
16
16
  import { registerInitiativeTab } from './c3-initiatives.js';
17
- import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
17
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, jiraLabel, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
18
18
  import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
19
19
  import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
20
20
  import { loadDocs, renderDocs } from './c3-ib-docs.js';
@@ -36,6 +36,10 @@ function tab(id, build) {
36
36
  });
37
37
  }
38
38
  const mine = (ctx, items) => items.filter((w) => w.ini === ctx.row.id);
39
+ /** Why an initiative shows no Jira item yet: no epic linked, or its epics not read yet. */
40
+ const epicsLine = (ctx) => ((ctx.row.epics || []).length
41
+ ? 'No Jira item read yet for ' + ctx.row.epics.join(', ') + '. The daily Jira check reads each epic and the issues under it.'
42
+ : 'No Jira epic is linked to ' + ctx.row.id + ' yet. Press Link Jira epics above to choose its epics' + (jiraLabel(ctx.board) ? ' in Jira ' + jiraLabel(ctx.board) : '') + '.');
39
43
 
40
44
  /* ── Overview ── */
41
45
  async function toolCard(ctx, t, R, rels) {
@@ -48,7 +52,7 @@ async function toolCard(ctx, t, R, rels) {
48
52
  case 'tickets': {
49
53
  const tk = its.filter((w) => w.src === 'jira');
50
54
  if (!b.build) body = [note(b.name + ' has Build and deliver off, so there is no Jira. Progress comes from PMOS tasks: ' + by('Done') + ' of ' + its.length + ' done.')];
51
- else if (!tk.length) body = [note('No Jira ' + (b.jira || '') + ' items linked yet.')];
55
+ else if (!tk.length) body = [note(epicsLine(ctx))];
52
56
  else {
53
57
  const d = tk.filter((w) => w.status === 'Done').length; const p = tk.filter((w) => /progress|review/i.test(w.status)).length; const td = tk.length - d - p;
54
58
  body = [hbs([hbr('Done', d, d / tk.length, 'ok'), hbr('In progress', p, p / tk.length, 'acc'), hbr('To do', td, td / tk.length, 'pm')]),
@@ -129,7 +133,7 @@ tab('overview', async (ctx, isDead, host) => {
129
133
  ['Owner', ownerText(r.owner)], ['Anchor', r.anchor || 'No anchor'], ['Horizon', r.horizon ? r.horizon[0].toUpperCase() + r.horizon.slice(1) : 'Not set'], ['Lane', r.lane || 'Not set']]),
130
134
  hbs([hbr('Progress', Math.round(p * 100) + '%', p, toneOf(r.status))]),
131
135
  el('div', { class: 'chips2', 'data-ini': 'board-links' }, ...['backlog', 'board', 'timeline'].map((k) => lk({ 'data-ini-boardlink': k, onclick: () => ctx.openBoard(b.id, k, { ini: r.id }) }, icon('next', 'sm'), b.name + ' ' + k[0].toUpperCase() + k.slice(1) + ', this initiative only')))),
132
- card({ ic: 'bolt', title: 'Work', sub: b.build ? 'Jira ' + (b.jira || '') + ' and PMOS' : 'PMOS tasks' },
136
+ card({ ic: 'bolt', title: 'Work', sub: b.build ? 'Jira ' + jiraLabel(b) + ' and PMOS' : 'PMOS tasks' },
133
137
  note(plural(work.filter((w) => w.kind === 'Epic').length, 'epic') + ', ' + plural(work.filter((w) => w.kind !== 'Epic').length, 'item') + '.'), el('div', { class: 'chips2' }, lk({ onclick: () => ctx.go({ view: 'ini', board: b.id, id: r.id, tab: 'breakdown' }) }, icon('next', 'sm'), 'Open breakdown'))),
134
138
  rels.length ? card({ ic: 'out', title: 'Releases', sub: 'live in the Calendar' }, chgl(rels.map((x) => el('li', null, icon('out', 'sm'), el('span', null, (x.name || x.id) + ' · ' + day(String(x.release_at || x.deploy_at || '').slice(0, 10))), tag('Calendar'))))) : null,
135
139
  ];
@@ -196,7 +200,7 @@ tab('tickets', async (ctx, isDead) => {
196
200
  if (isDead()) return null;
197
201
  if (!R.ok) return errLine('The Jira items could not be read: ' + R.error + '.');
198
202
  const tk = mine(ctx, R.items).filter((w) => w.src === 'jira');
199
- if (!tk.length) return empty('No Jira ' + (ctx.board.jira || '') + ' items are linked to ' + ctx.row.id + ' yet.', { 'data-inb': 'tk-empty' });
203
+ if (!tk.length) return empty(epicsLine(ctx), { 'data-inb': 'tk-empty' });
200
204
  const its = tk.filter((w) => w.kind !== 'Epic'); const n = Math.max(1, its.length);
201
205
  const c = (s) => its.filter((w) => w.status === s).length;
202
206
  return cols(
@@ -75,6 +75,8 @@ export function geo(node, vars) {
75
75
  }
76
76
 
77
77
  /* ── text bits ── */
78
+ /** The Jira projects a board mirrors, for a sentence: "IDPF, LB" (a board may map several Jira projects). */
79
+ export const jiraLabel = (b) => ((b && Array.isArray(b.jiraProjects) && b.jiraProjects.length) ? b.jiraProjects.join(', ') : (b && b.jira) || '');
78
80
  export const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many || one + 's');
79
81
  export const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
80
82
  export const chip = (...kids) => el('span', { class: 'chip2' }, ...kids);
@@ -42,7 +42,7 @@ import { ensureC3Css } from './pm-work-view.js';
42
42
  import { openSheet, closeSheet } from './c3-sheet.js';
43
43
  import { toast } from './c3-toast.js';
44
44
  import {
45
- btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
45
+ btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, jiraLabel, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
46
46
  stageChip, stepsMini, progChip, dateChip, list, empty, sect, card, nrow, crumbs, lh, segTabs, cols, hbs, hbr, kv, chgl, chgli, openMenu, closeMenu, CATS, PORTFOLIO,
47
47
  } from './c3-ini-ui.js';
48
48
  import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf, catOf, kindOf, tplName, TOOL_META, toolLabel, toolIcon, loadItems, dropItems, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
@@ -107,7 +107,7 @@ function ensureCss() {
107
107
  document.head.appendChild(l);
108
108
  }
109
109
  const warnChip = (w) => st('warn', 'alert', w, { title: 'An order rule, shown as a warning. It never blocks.', 'data-ini-warning': w });
110
- const buildTag = (b) => (b.build ? el('span', { class: 'tag' }, icon('jira', 'sm'), b.jira ? 'Jira · ' + b.jira : 'Jira not set') : el('span', { class: 'tag' }, 'PMOS tasks only'));
110
+ const buildTag = (b) => (b.build ? el('span', { class: 'tag' }, icon('jira', 'sm'), jiraLabel(b) ? 'Jira · ' + jiraLabel(b) : 'Jira not set') : el('span', { class: 'tag' }, 'PMOS tasks only'));
111
111
  const errLine = (text) => el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), text);
112
112
  const sentenceOf = (bad, warn) => (bad.length || warn.length
113
113
  ? [bad.length ? plural(bad.length, 'board') + (bad.length === 1 ? ' is' : ' are') + ' off track' : '', warn.length ? plural(warn.length, 'board') + ' need' + (warn.length === 1 ? 's' : '') + ' attention' : ''].filter(Boolean).join(', ')
@@ -271,7 +271,7 @@ export function mountInitiatives(pane, opts) {
271
271
  const s = pick.map((i) => i.start).sort()[0]; const e = pick.map((i) => i.end).sort().pop();
272
272
  const avg = pick.reduce((n, i) => n + (Number(i.progress) || 0), 0) / pick.length;
273
273
  rows.push({ uid: 'b:' + b.id, label: b.name + ' · ' + pick.length + ' of ' + now.length, start: s, end: e, progress: avg, cls: 'grp', tone: b.health === 'bad' ? 'bad' : b.health === 'warn' ? 'warn' : 'ok',
274
- open: () => openBoard(b.id, 'timeline'), tip: { t: b.name + (b.full ? ' · ' + b.full : ''), p: Math.round(avg * 100) + '%', st: HEALTH[b.health] || 'No health yet', o: b.owner || '', src: b.build ? 'Board · Jira ' + (b.jira || '') : 'Board · PMOS tasks only', hint: 'Click the row to fold the board' },
274
+ open: () => openBoard(b.id, 'timeline'), tip: { t: b.name + (b.full ? ' · ' + b.full : ''), p: Math.round(avg * 100) + '%', st: HEALTH[b.health] || 'No health yet', o: b.owner || '', src: b.build ? 'Board · Jira ' + jiraLabel(b) : 'Board · PMOS tasks only', hint: 'Click the row to fold the board' },
275
275
  kids: pick.map((i) => iniTlRow(ctx, i, workTlRows(ctx, r.items.filter((w) => w.ini === i.id), () => { dropItems(ctx); draw(); }), b)) });
276
276
  }
277
277
  const { from, to } = tlRange(P.today);
@@ -318,7 +318,7 @@ export function mountInitiatives(pane, opts) {
318
318
  lh('cols', b.name + (b.full ? ' · ' + b.full : ''), (HEALTH[b.health] || 'No health yet') + (risk ? ', ' + risk + ' at risk or late' : '') + (asks ? ', ' + plural(asks, 'new ask') + ' to triage' : '') + '. '
319
319
  + plural(c.total || 0, 'initiative') + (items.ok ? ', ' + plural(work.filter((w) => w.kind === 'Epic').length, 'epic') + ', ' + plural(work.filter((w) => w.kind !== 'Epic').length, 'item') : '') + '.'),
320
320
  el('div', { class: 'chips2', 'aria-label': 'Board setup', 'data-ini': 'setup' },
321
- ...(b.build ? [st('ok', 'bolt', 'Build and deliver on'), chip(icon('jira', 'sm'), b.jira ? ['Jira project ', el('b', { class: 'mono' }, b.jira), ' · ' + plural(jiraN, 'item')] : 'No Jira project set'), chip('PMOS tasks ' + pmN)]
321
+ ...(b.build ? [st('ok', 'bolt', 'Build and deliver on'), chip(icon('jira', 'sm'), jiraLabel(b) ? [(b.jiraProjects || []).length > 1 ? 'Jira projects ' : 'Jira project ', el('b', { class: 'mono' }, jiraLabel(b)), ' · ' + plural(jiraN, 'item')] : 'No Jira project set'), chip('PMOS tasks ' + pmN)]
322
322
  : [chip(icon('x', 'sm'), 'Build and deliver off'), chip('No Jira · ' + plural(pmN, 'PMOS task'))]),
323
323
  chip(icon('flag', 'sm'), 'New initiatives start with ' + def.name), lk({ onclick: () => openBoardSettings(b), 'data-ini': 'setup-change' }, 'Change')),
324
324
  b.self ? note(SELF_WHY) : null,
@@ -356,7 +356,7 @@ export function mountInitiatives(pane, opts) {
356
356
  [card({ ic: 'flag', title: 'Where the initiatives are', sub: 'each on its own workflow' },
357
357
  hbs(CATS.map((c) => btn({ class: 'ini-hbb', 'data-ini-catgo': c, title: 'See ' + c + ' initiatives', onclick: () => goIni({ view: 'all', cat: c }) }, hbr([sw(c), ' ', c], byCat(c), byCat(c) / maxN, 'acc', { class: 'hbr ini-c', 'data-cat': c })))),
358
358
  note('Stages roll up into four categories. Press one to list its initiatives.')),
359
- card({ ic: 'todo', title: 'Work items', sub: b.build ? 'Jira ' + (b.jira || '') + ' and PMOS' : 'PMOS tasks' },
359
+ card({ ic: 'todo', title: 'Work items', sub: b.build ? 'Jira ' + jiraLabel(b) + ' and PMOS' : 'PMOS tasks' },
360
360
  hbs(['To do', 'In progress', 'In review', 'Done'].map((s) => hbr(s, nSt(s), nSt(s) / tot, s === 'Done' ? 'ok' : s === 'To do' ? 'pm' : 'acc'))),
361
361
  el('div', { class: 'chips2' }, lk({ onclick: () => ctx.openBoard(b.id, 'board') }, 'Open the Board'), lk({ onclick: () => ctx.openBoard(b.id, 'timeline') }, 'Open the Timeline'))),
362
362
  card({ ic: 'report', title: 'At a glance' }, chgl([
@@ -381,7 +381,7 @@ export function mountInitiatives(pane, opts) {
381
381
 
382
382
  function openBoardSettings(b) {
383
383
  if (b.self) { openSheet({ title: 'Board settings · ' + b.name, icon: 'lock', body: el('div', { class: 'ini-form' }, note(SELF_WHY)) }); return; }
384
- const B = { build: b.build !== false, jira: b.jira || '', offer: new Set(offered(b)), def: b.defTpl || offered(b)[0], err: '', busy: false };
384
+ const B = { build: b.build !== false, jira: jiraLabel(b), offer: new Set(offered(b)), def: b.defTpl || offered(b)[0], err: '', busy: false };
385
385
  const host = el('div', { class: 'ini-form', 'data-ini': 'settings-form' });
386
386
  const sh = openSheet({ title: 'Board settings · ' + b.name, icon: 'cols', body: host, wide: true });
387
387
  let usage = null;
@@ -394,11 +394,11 @@ export function mountInitiatives(pane, opts) {
394
394
  const inis = usage && usage.ok ? usage.initiatives : [];
395
395
  const jiraN = usage && usage.ok ? usage.items.filter((w) => w.src === 'jira').length : 0;
396
396
  const usingBuild = inis.filter((i) => wfOf(i, b).some(isBuildStage)).length;
397
- const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project key', placeholder: 'Project key, e.g. IDPF', 'data-ini': 'jira-key', oninput: (e) => { B.jira = e.target.value; } });
397
+ const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project keys', placeholder: 'Project keys, e.g. IDPF, LB', 'data-ini': 'jira-key', oninput: (e) => { B.jira = e.target.value; } });
398
398
  host.replaceChildren(
399
399
  note('Settings for the board. Workflows belong to each initiative; here you choose which templates the board offers and which one new initiatives start with.'),
400
400
  checkbox(B.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'build' }, (e) => { B.build = e.target.checked; if (B.build) Object.keys(TEMPLATES).filter(templateBuilds).forEach((k) => B.offer.add(k)); render('[data-ini="build"]'); }),
401
- B.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECT'), el('div', { class: 'form' }, jira), note('Shows Jira epics, stories, tasks and bugs next to PMOS tasks, and adds the Sprints and Releases tabs (mirrored, read-only).'))
401
+ B.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, jira), note('One or more Jira projects, separated by commas. Shows their epics, stories, tasks and bugs next to PMOS tasks, and adds the Sprints and Releases tabs (mirrored, read-only). Link each initiative to its Jira epics on the initiative page.'))
402
402
  : note('Off: Backlog, Board and Timeline show PMOS tasks only. No Sprints or Releases tab, and initiatives choose workflows without Build and Deliver.'),
403
403
  !B.build && b.build ? el('div', { class: 'fold w', role: 'alert', 'data-ini': 'build-off-warning' }, icon('alert', 'sm'), el('span', null, plural(jiraN, 'Jira item') + ' will be hidden from this board.' + (usingBuild ? ' ' + plural(usingBuild, 'initiative') + ' use Build or Deliver stages; they keep them until you edit their workflow.' : ''))) : null,
404
404
  lbl('WORKFLOWS THIS BOARD OFFERS'),
@@ -416,16 +416,16 @@ export function mountInitiatives(pane, opts) {
416
416
  }
417
417
  async function save() {
418
418
  const key = B.jira.trim();
419
- if (B.build && key && !/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(key)) { B.err = 'A Jira project key is a letter, then letters, digits or _.'; render(); return; }
419
+ if (B.build && key && !key.split(/[,\s]+/).filter(Boolean).every((k) => /^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(k))) { B.err = 'Each Jira project key is a letter, then letters, digits or _. Separate keys with a comma.'; render(); return; }
420
420
  B.busy = true; B.err = ''; render();
421
421
  const body = { board: b.id, buildDeliver: B.build, offer: [...B.offer], defTpl: B.def };
422
- if (B.build && key) body.jira = key;
422
+ if (B.build) body.jira = key;
423
423
  const r = await apiFor(b.ws).post('/api/boards/settings', body);
424
424
  B.busy = false;
425
425
  if (!r || !r.ok) { B.err = 'The settings were not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
426
426
  sh.close();
427
427
  const nb = r.board || {};
428
- toast(b.name + ': Build and deliver ' + (nb.build ? 'on' + (nb.jira ? ', mirroring Jira ' + nb.jira : '') : 'off, PMOS tasks only') + '. New initiatives start with ' + (TEMPLATES[nb.defTpl] || TEMPLATES[B.def]).name + '.');
428
+ toast(b.name + ': Build and deliver ' + (nb.build ? 'on' + (jiraLabel(nb) ? ', mirroring Jira ' + jiraLabel(nb) : '') : 'off, PMOS tasks only') + '. New initiatives start with ' + (TEMPLATES[nb.defTpl] || TEMPLATES[B.def]).name + '.');
429
429
  refresh();
430
430
  }
431
431
  render();
@@ -445,7 +445,7 @@ export function mountInitiatives(pane, opts) {
445
445
  lbl('BOARD NAME'), el('div', { class: 'form' }, name),
446
446
  wsList.length > 1 ? el('label', { class: 'ini-field' }, lbl('WORKSPACE'), el('select', { 'data-ini': 'nb-ws', onchange: (e) => { N.ws = e.target.value; } }, ...wsList.map((w) => el('option', { value: w.id, selected: w.id === N.ws }, w.name || w.id)))) : null,
447
447
  checkbox(N.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'nb-build' }, (e) => { N.build = e.target.checked; render('[data-ini="nb-build"]'); }),
448
- N.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECT'), el('div', { class: 'form' }, el('input', { type: 'text', value: N.jira, placeholder: 'Project key, e.g. PP', 'aria-label': 'Jira project key', 'data-ini': 'nb-jira', oninput: (e) => { N.jira = e.target.value; } })),
448
+ N.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, el('input', { type: 'text', value: N.jira, placeholder: 'Project keys, e.g. PP, PPM', 'aria-label': 'Jira project keys', 'data-ini': 'nb-jira', oninput: (e) => { N.jira = e.target.value; } })),
449
449
  note('Epics, stories, tasks and bugs from this project show next to PMOS tasks. Sprints and releases are mirrored, read-only.'))
450
450
  : note('Off: no Jira. Initiatives here use workflows without Build and Deliver, and the work is PMOS tasks. Good for a microsite run by an agency, an office move or hiring.'),
451
451
  lbl('DEFAULT WORKFLOW FOR NEW INITIATIVES'), tplSeg(allowed, N.tpl, (k) => { N.tpl = k; render('[data-ini-tpl="' + k + '"]'); }, 'Default workflow'), tplCase(N.tpl),
@@ -690,6 +690,7 @@ export function mountInitiatives(pane, opts) {
690
690
  el('div', { class: 'calbody', 'data-ini-page': row.id },
691
691
  lh('target', row.title || row.id, [row.id, bb.name, row.anchor || 'no anchor', row.owner || 'no owner', row.start || row.end ? day(row.start) + ' to ' + day(row.end) : 'no dates yet', row.horizon ? row.horizon[0].toUpperCase() + row.horizon.slice(1) : '', Math.round((Number(row.progress) || 0) * 100) + '% done'].filter(Boolean).join(' · ')),
692
692
  el('div', { class: 'chips2', 'data-ini': 'ini-chips' }, statusChip(row.status), chip(icon('bolt', 'sm'), plural(ep, 'epic') + ', ' + plural(work.length - ep, 'item')), buildTag(bb),
693
+ bb.build ? pill({ 'data-ini': 'jira-epics', 'aria-haspopup': 'dialog', title: 'The Jira epics of this initiative', onclick: () => openEpics(bb, row) }, icon('jira', 'sm'), (row.epics || []).length ? 'Jira epics ' + row.epics.join(', ') : 'Link Jira epics') : null,
693
694
  row.lane ? tag(row.lane) : null, el('span', { class: 'chips2', 'data-ini': 'warnings', 'aria-live': 'polite' }, ...warnings.map(warnChip))),
694
695
  stepper, bb.self ? note(SELF_WHY) : null, strip,
695
696
  segTabs(tabs, tab, (k) => go({ view: 'ini', board: bb.id, id: row.id, tab: k }), 'Initiative views', { 'data-ini': 'ini-tabs' }),
@@ -697,6 +698,34 @@ export function mountInitiatives(pane, opts) {
697
698
  if (P.focusStep) { const f = stepper.querySelector('[data-ini-step="' + CSS.escape(P.focusStep) + '"]'); P.focusStep = null; if (f) f.focus(); }
698
699
  mountTab(host, null, (INI_TAB_LIST.find((t) => t[0] === tab) || [])[1], ctxI, token);
699
700
  }
701
+ /* The Jira epics of an initiative: the daily Jira check reads each one and the issues under it, read-only. */
702
+ function openEpics(b, row) {
703
+ const E = { text: (row.epics || []).join(', '), err: '', busy: false };
704
+ const host = el('div', { class: 'ini-form', 'data-ini': 'epics-form' });
705
+ const sh = openSheet({ title: 'Jira epics · ' + row.id, icon: 'bolt', body: host });
706
+ function render() {
707
+ const input = el('input', { type: 'text', value: E.text, 'aria-label': 'Jira epic keys', placeholder: 'Epic keys, e.g. IDPF-2685, LB-120', 'data-ini': 'epics-input', oninput: (e) => { E.text = e.target.value; } });
708
+ host.replaceChildren(
709
+ note('The Jira epics this initiative runs on. The daily Jira check reads each epic and the issues under it, read-only, and shows them as its Jira items.' + (jiraLabel(b) ? ' ' + b.name + ' mirrors Jira ' + jiraLabel(b) + '.' : '')),
710
+ row.epicsFrom === 'record' ? note('Now from the initiative record (jira: ' + row.epics.join(', ') + '). Saving here takes over from it.') : null,
711
+ el('div', { class: 'ini-field' }, lbl('JIRA EPICS'), el('div', { class: 'form' }, input)),
712
+ E.err ? errLine(E.err) : null,
713
+ el('div', { class: 'chips2' }, pbtn('primary', E.busy ? 'Saving' : 'Save epics', null, { 'data-ini': 'epics-save', disabled: E.busy, onclick: save }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
714
+ input.focus();
715
+ }
716
+ async function save() {
717
+ const keys = E.text.split(/[,\s]+/).filter(Boolean);
718
+ if (!keys.every((k) => /^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(k))) { E.err = 'An epic key is a project key, a dash and a number, like IDPF-2685. Separate keys with a comma.'; render(); return; }
719
+ E.busy = true; E.err = ''; render();
720
+ const r = await apiFor(b.ws).post('/api/boards/ini-epics', { board: b.id, id: row.id, epics: keys });
721
+ E.busy = false;
722
+ if (!r || !r.ok) { E.err = 'The epics were not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
723
+ sh.close();
724
+ toast(keys.length ? row.id + ' runs on Jira ' + keys.map((k) => k.toUpperCase()).join(', ') + '. The next Jira check reads them.' : row.id + ' has no Jira epic now.');
725
+ P.ini = null; dropItems(); draw();
726
+ }
727
+ render();
728
+ }
700
729
  function pickTool(id) {
701
730
  if (P.loc.tab !== 'overview' || P.loc.tool !== id) { go({ ...P.loc, tab: 'overview', tool: id }); return; }
702
731
  const n = root.querySelector('[data-ini-tool-card="' + CSS.escape(id) + '"]');