create-pmos 0.133.0 → 0.135.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/notes-replay.js +235 -0
- package/workspace/c3-briefing-test.js +130 -0
- package/workspace/c3-foundation-test.js +159 -0
- package/workspace/c3-ib-items-test.js +105 -0
- package/workspace/c3-ib-more-test.js +120 -0
- package/workspace/c3-ib-triage-backlog-test.js +119 -0
- package/workspace/c3-ini-harness.js +66 -0
- package/workspace/c3-initiatives-test.js +140 -0
- package/workspace/c3-one-home-test.js +14 -1
- package/workspace/c3-reports-test.js +57 -18
- package/workspace/c3-settings-test.js +139 -0
- package/workspace/c3-work-test.js +42 -10
- package/workspace/fixtures/one-home-baseline.json +1 -9
- package/workspace/guide/briefing-cards.md +15 -3
- package/workspace/guide/briefing.md +12 -8
- package/workspace/guide/calendar-popup.md +2 -2
- package/workspace/guide/console-calendar.md +39 -10
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-boards.md +65 -0
- package/workspace/guide/jira.md +3 -1
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/my-work.md +5 -3
- package/workspace/guide/notes.md +35 -2
- package/workspace/guide/reports-dashboards.md +29 -5
- package/workspace/guide/settings.md +68 -0
- package/workspace/guide/welcome.md +10 -10
- package/workspace/lib/calendar-cli.js +3 -2
- package/workspace/lib/calendar-layers.js +21 -10
- package/workspace/lib/calendar-tidy.js +230 -0
- package/workspace/lib/engine/boards.js +542 -0
- package/workspace/lib/engine/breakdown-ops.js +180 -0
- package/workspace/lib/engine/calendar-import.js +84 -0
- package/workspace/lib/engine/calendar-model.js +185 -0
- package/workspace/lib/engine/card-state.js +92 -0
- package/workspace/lib/engine/derived/derive.js +92 -0
- package/workspace/lib/engine/derived/plan.js +3 -0
- package/workspace/lib/engine/derived/trust.js +3 -0
- package/workspace/lib/engine/doc-health.js +142 -0
- package/workspace/lib/engine/doc-sheet.js +75 -0
- package/workspace/lib/engine/intake.js +78 -0
- package/workspace/lib/engine/outbox.js +86 -0
- package/workspace/lib/engine/plan-forecast.js +342 -0
- package/workspace/lib/engine/promises.js +29 -0
- package/workspace/lib/engine/records/index.js +114 -0
- package/workspace/lib/engine/releases.js +96 -0
- package/workspace/lib/engine/replies.js +99 -0
- package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
- package/workspace/lib/engine/report-families.js +175 -0
- package/workspace/lib/engine/roadmap-ops.js +150 -0
- package/workspace/lib/engine/sessions-list.js +27 -0
- package/workspace/lib/engine/signal-rules.js +95 -0
- package/workspace/lib/engine/signals/cards.js +57 -0
- package/workspace/lib/engine/signals/detectors.js +370 -0
- package/workspace/lib/engine/signals/facts-cli.js +8 -0
- package/workspace/lib/engine/signals/facts.js +197 -0
- package/workspace/lib/engine/signals/index.js +33 -0
- package/workspace/lib/engine/signals/kinds.js +110 -0
- package/workspace/lib/engine/signals/layout-paths.js +44 -0
- package/workspace/lib/engine/signals/options.js +24 -0
- package/workspace/lib/engine/signals/score.js +72 -0
- package/workspace/lib/engine/signals/select.js +47 -0
- package/workspace/lib/engine/step-planner.js +65 -0
- package/workspace/lib/engine/trust/claim-rules.json +93 -0
- package/workspace/lib/engine/trust/import-calendar.js +21 -0
- package/workspace/lib/engine/trust/import-guard.js +36 -0
- package/workspace/lib/engine/trust/ledger.js +53 -0
- package/workspace/lib/engine/trust/outbox.js +45 -0
- package/workspace/lib/engine/trust/plan.js +5 -0
- package/workspace/lib/engine/trust/trust.js +21 -0
- package/workspace/lib/engine/truth-table.js +57 -0
- package/workspace/lib/engine/workspace-lib.js +24 -0
- package/workspace/lib/factory-join.js +1 -1
- package/workspace/lib/facts-async.js +4 -4
- package/workspace/lib/headless.js +95 -0
- package/workspace/lib/import-overlay.js +2 -1
- package/workspace/lib/jira-status.js +30 -3
- package/workspace/lib/notes-audio.js +28 -9
- package/workspace/lib/notes-capture.js +136 -0
- package/workspace/lib/notes-final.js +38 -13
- package/workspace/lib/notes-live-guard.js +117 -0
- package/workspace/lib/notes-replay.js +125 -0
- package/workspace/lib/notes-screen.js +180 -0
- package/workspace/lib/notes-whisper.js +139 -16
- package/workspace/lib/notes.js +4 -2
- package/workspace/lib/outbox-store.js +10 -6
- package/workspace/lib/pmos-verbs.js +3 -1
- package/workspace/lib/proactive-cli.js +7 -0
- package/workspace/lib/proactive.js +15 -2
- package/workspace/lib/repo-script.js +36 -0
- package/workspace/lib/report-dashboards.js +5 -2
- package/workspace/lib/report-frame.js +122 -0
- package/workspace/lib/report-schedules.js +113 -0
- package/workspace/lib/route-modules.js +9 -2
- package/workspace/lib/routes-boards.js +734 -0
- package/workspace/lib/routes-brief-page.js +13 -4
- package/workspace/lib/routes-briefing.js +22 -11
- package/workspace/lib/routes-c3-init.js +4 -3
- package/workspace/lib/routes-c3-life.js +2 -1
- package/workspace/lib/routes-c3-reports.js +96 -13
- package/workspace/lib/routes-c3-strategy.js +4 -3
- package/workspace/lib/routes-c3-timeline.js +3 -2
- package/workspace/lib/routes-calendar-import.js +3 -2
- package/workspace/lib/routes-calendar-views.js +9 -4
- package/workspace/lib/routes-doc-health.js +2 -1
- package/workspace/lib/routes-doc-sheet.js +2 -1
- package/workspace/lib/routes-forecast.js +2 -1
- package/workspace/lib/routes-hooks.js +3 -2
- package/workspace/lib/routes-initiatives.js +2 -1
- package/workspace/lib/routes-intake.js +2 -1
- package/workspace/lib/routes-notes.js +113 -15
- package/workspace/lib/routes-outbox.js +23 -3
- package/workspace/lib/routes-pm-items.js +19 -6
- package/workspace/lib/routes-records.js +3 -2
- package/workspace/lib/routes-releases.js +2 -1
- package/workspace/lib/routes-report-facts.js +4 -3
- package/workspace/lib/routes-report-families.js +2 -1
- package/workspace/lib/routes-roadmap-ops.js +5 -4
- package/workspace/lib/routes-runs.js +6 -5
- package/workspace/lib/routes-settings.js +51 -0
- package/workspace/lib/routes-signals.js +3 -2
- package/workspace/lib/routes-stage-tools.js +220 -0
- package/workspace/lib/run-answers.js +2 -1
- package/workspace/lib/settings-store.js +226 -0
- package/workspace/lib/stage-tools.js +208 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
- package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
- package/workspace/lib/work-evidence.js +73 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +63 -17
- package/workspace/public/pm/briefing-model.js +36 -7
- package/workspace/public/pm/c3-ambient.js +100 -0
- package/workspace/public/pm/c3-brief-needs.js +26 -6
- package/workspace/public/pm/c3-brief-pulse.js +12 -4
- package/workspace/public/pm/c3-brief-today.js +56 -21
- package/workspace/public/pm/c3-brief.js +2 -1
- package/workspace/public/pm/c3-calendar.js +161 -42
- package/workspace/public/pm/c3-calpop.js +5 -5
- package/workspace/public/pm/c3-demo-boards.js +649 -0
- package/workspace/public/pm/c3-demo-brief-page.js +1 -1
- package/workspace/public/pm/c3-demo-cal4.js +3 -2
- package/workspace/public/pm/c3-demo-cal5.js +52 -0
- package/workspace/public/pm/c3-demo-data.js +17 -27
- package/workspace/public/pm/c3-demo-rep3.js +46 -5
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-dock.js +1 -1
- package/workspace/public/pm/c3-ib-docs.js +80 -0
- package/workspace/public/pm/c3-ib-initiatives.js +134 -0
- package/workspace/public/pm/c3-ib-items.js +201 -0
- package/workspace/public/pm/c3-ib-releases.js +51 -0
- package/workspace/public/pm/c3-ib-sprints.js +55 -0
- package/workspace/public/pm/c3-ib-triage.js +178 -0
- package/workspace/public/pm/c3-ii-tabs.js +240 -0
- package/workspace/public/pm/c3-ini-records.js +197 -0
- package/workspace/public/pm/c3-ini-timeline.js +278 -0
- package/workspace/public/pm/c3-ini-ui.js +229 -0
- package/workspace/public/pm/c3-ini-work.js +139 -0
- package/workspace/public/pm/c3-initiatives.css +218 -0
- package/workspace/public/pm/c3-initiatives.js +741 -0
- package/workspace/public/pm/c3-plan.js +2 -2
- package/workspace/public/pm/c3-reports.css +47 -47
- package/workspace/public/pm/c3-reports.js +223 -185
- package/workspace/public/pm/c3-settings.css +74 -0
- package/workspace/public/pm/c3-settings.js +703 -0
- package/workspace/public/pm/c3-shell.js +102 -36
- package/workspace/public/pm/c3-terminal-icons.js +2 -2
- package/workspace/public/pm/c3-terminal.js +2 -2
- package/workspace/public/pm/calendar-views.js +26 -13
- package/workspace/public/pm/components.css +319 -213
- package/workspace/public/pm/console3.css +118 -23
- package/workspace/public/pm/dest-notes.js +26 -4
- package/workspace/public/pm/notes-screen-capture.js +165 -0
- package/workspace/public/pm/notes-screen-view.js +98 -0
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/pm-work-view.js +233 -146
- package/workspace/public/pm/shell-bell.js +3 -2
- package/workspace/public/pm/shell-recording.js +148 -17
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/public/pm/view-notes.js +53 -11
- package/workspace/public/pm/welcome.js +197 -89
- package/workspace/public/welcome.html +6 -4
- package/workspace/server.js +105 -6
- package/workspace/skills/day-check.skill +1 -1
- package/workspace/skills/jira-sync.skill +6 -4
- package/workspace/skills/morning-brief.skill +1 -1
- package/workspace/test-files.json +14 -3
- package/workspace/test-lists/c3-ib-items.json +9 -0
- package/workspace/test-lists/c3-ib-more.json +9 -0
- package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
- package/workspace/test-lists/c3-initiative-boards.json +6 -0
- package/workspace/test-lists/calendar-tidy-up.json +6 -0
- package/workspace/test-lists/console3-briefing.json +9 -0
- package/workspace/test-lists/console3-foundation.json +9 -0
- package/workspace/test-lists/console3-reports.json +3 -3
- package/workspace/test-lists/meeting-notes.json +12 -4
- package/workspace/test-lists/workspace-headless-jobs.json +5 -0
- package/workspace/test-lists/workspace-package-script-closure.json +6 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
- package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
- package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
- package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
- package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
- package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
- package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
- package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
- package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
/* pm/c3-initiatives.js — the Initiatives tab of /c3.html (mockup v5, src/pages/34-initiatives.js, approved 2026-10-11).
|
|
2
|
+
* Hierarchy: Portfolio > Board > Initiative > Epic > Story, Task or Bug. A board offers workflow templates and a default; each
|
|
3
|
+
* initiative runs its own workflow. A board with "Build and deliver" on mirrors a Jira project (its items, Releases and Sprints); a
|
|
4
|
+
* board with it off has PMOS tasks only. c3-shell.js hands the pane to mountInitiatives(pane, { ws, workspaces, demo, showTab }).
|
|
5
|
+
*
|
|
6
|
+
* WHAT THIS FILE DRAWS
|
|
7
|
+
* Portfolio breadcrumb, the sentence on board health, the boards (one row each: health, owner, Build and deliver, the category
|
|
8
|
+
* stack, risk, new asks, next release), a card per category across the boards, the asks waiting, and the timeline
|
|
9
|
+
* across boards (board > initiative > epic). "New board" opens a sheet (name, Build and deliver, Jira key, default
|
|
10
|
+
* workflow).
|
|
11
|
+
* Board breadcrumb, Dashboard and Board settings, the sentence, the setup chips (Build and deliver, Jira, PMOS tasks, the
|
|
12
|
+
* default workflow), then the tabs Summary · Initiatives · Triage · Backlog · Board · Timeline · Releases · Docs ·
|
|
13
|
+
* Sprints (Releases and Sprints only when Build and deliver is on). Summary is drawn here; the rest come from the registry.
|
|
14
|
+
* Initiative breadcrumb, the sentence, status and warning chips, the stepper of its own workflow (a press is POST /api/boards/move;
|
|
15
|
+
* the warnings show, nothing is blocked), Edit workflow, the tools of the current stage and a More tools menu, then the
|
|
16
|
+
* tabs Overview · Breakdown · Docs · Jira items (Build and deliver only) · Timeline · History, from the registry.
|
|
17
|
+
* Sheets Board settings (POST /api/boards/settings), New board (POST /api/boards/create), New initiative (an ask added and
|
|
18
|
+
* accepted at once: POST /api/boards/asks/add then asks/accept, then ini-workflow when it picks another template),
|
|
19
|
+
* and the workflow editor in two modes: one initiative's stages (POST /api/boards/ini-workflow) or the board's own
|
|
20
|
+
* workflow, the one its initiatives without their own run (POST /api/boards/workflow).
|
|
21
|
+
*
|
|
22
|
+
* URL STATE `init=` with history.replaceState, read back on load:
|
|
23
|
+
* (none) Portfolio
|
|
24
|
+
* board:<board>:<tab>[:<filter>] a board tab; <filter> is a query string (cat=Do&view=risk), each part URI-encoded
|
|
25
|
+
* ini:<board>:<id>:<tab>[:<tool>] an initiative tab; <tool> is the tool the Overview shows first
|
|
26
|
+
*
|
|
27
|
+
* THE TAB REGISTRY registerBoardTab(id, mount) · registerInitiativeTab(id, mount). Modules, each in its own guarded import():
|
|
28
|
+
* ./c3-ib-initiatives.js ./c3-ib-triage.js ./c3-ib-items.js (backlog, board, timeline) ./c3-ib-releases.js ./c3-ib-docs.js
|
|
29
|
+
* ./c3-ib-sprints.js ./c3-ii-tabs.js (the six initiative tabs)
|
|
30
|
+
* mount(host, ctx) draws into host and may return (or resolve to) a function called when the PM leaves the tab. The ctx, frozen:
|
|
31
|
+
* ws, board (a row of GET /api/boards: id, name, full, template, workflow, build, jira, offer, defTpl, tracker, sprints, health,
|
|
32
|
+
* counts, asks, nextRelease), today ('YYYY-MM-DD' from the server), tab, filter (board tabs), row, history, tools, toolsByCat, tool
|
|
33
|
+
* (initiative tabs), api.get/post, go(loc), openInitiative(id, tab?, board?), openBoard(id, tab?, filter?), toast, openSheet,
|
|
34
|
+
* refresh() (read the boards again and draw), redraw() (draw the current place again, keeping the reads), demo, showTab,
|
|
35
|
+
* moveTo(id, stage) -> Promise<{ ok, row, warnings }>, openWorkflow(row), openBoardSettings(), newInitiative()
|
|
36
|
+
*
|
|
37
|
+
* Every control is a <button> (primitives.js button() through c3-ini-ui.js btn()); geometry is a --in-… custom property; no dialog. */
|
|
38
|
+
import { el } from './dom.js';
|
|
39
|
+
import { ensureComponentsCss } from './primitives.js';
|
|
40
|
+
import { pmGet, pmPost } from './http.js';
|
|
41
|
+
import { ensureC3Css } from './pm-work-view.js';
|
|
42
|
+
import { openSheet, closeSheet } from './c3-sheet.js';
|
|
43
|
+
import { toast } from './c3-toast.js';
|
|
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,
|
|
46
|
+
stageChip, stepsMini, progChip, dateChip, list, empty, sect, card, nrow, crumbs, lh, segTabs, cols, hbs, hbr, kv, chgl, chgli, openMenu, closeMenu, CATS, PORTFOLIO,
|
|
47
|
+
} from './c3-ini-ui.js';
|
|
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';
|
|
49
|
+
import { timeline, tlInit, tlRange } from './c3-ini-timeline.js';
|
|
50
|
+
import { RECORD_META, recordToolsFor } from './c3-ini-records.js';
|
|
51
|
+
|
|
52
|
+
export { btn, geo };
|
|
53
|
+
export { TEMPLATES, CATS };
|
|
54
|
+
|
|
55
|
+
/* ── the registry ── */
|
|
56
|
+
export const BOARD_TABS = {};
|
|
57
|
+
export const INITIATIVE_TABS = {};
|
|
58
|
+
export function registerBoardTab(id, mount) { if (typeof mount === 'function') BOARD_TABS[id] = mount; }
|
|
59
|
+
export function registerInitiativeTab(id, mount) { if (typeof mount === 'function') INITIATIVE_TABS[id] = mount; }
|
|
60
|
+
|
|
61
|
+
export const BOARD_TAB_LIST = [['summary', 'Summary', 'report'], ['initiatives', 'Initiatives', 'init'], ['triage', 'Triage', 'inbox'], ['backlog', 'Backlog', 'todo'], ['board', 'Board', 'cols'], ['timeline', 'Timeline', 'cal'], ['releases', 'Releases', 'out'], ['docs', 'Docs', 'doc'], ['sprints', 'Sprints', 'clock']];
|
|
62
|
+
export const INI_TAB_LIST = [['overview', 'Overview', 'init'], ['breakdown', 'Breakdown', 'tree'], ['docs', 'Docs', 'doc'], ['tickets', 'Jira items', 'check'], ['timeline', 'Timeline', 'cal'], ['history', 'History', 'undo']];
|
|
63
|
+
const BUILD_ONLY = ['releases', 'sprints'];
|
|
64
|
+
const BOARD_IDS = BOARD_TAB_LIST.map((t) => t[0]);
|
|
65
|
+
const INI_IDS = INI_TAB_LIST.map((t) => t[0]);
|
|
66
|
+
const MODULES = ['./c3-ib-initiatives.js', './c3-ib-triage.js', './c3-ib-items.js', './c3-ib-releases.js', './c3-ib-docs.js', './c3-ib-sprints.js', './c3-ii-tabs.js'];
|
|
67
|
+
let modulesReady = null;
|
|
68
|
+
/** Import every tab module once. A failed import is recorded, never thrown. */
|
|
69
|
+
export function loadTabModules() {
|
|
70
|
+
if (!modulesReady) modulesReady = Promise.all(MODULES.map((p) => import(p).then(() => null, (e) => ({ path: p, error: String((e && e.message) || e) })))).then((r) => r.filter(Boolean));
|
|
71
|
+
return modulesReady;
|
|
72
|
+
}
|
|
73
|
+
const SELF_WHY = 'This board follows the PMOS ladder. The repository derives each stage from its records, so a stage cannot be moved or edited here.';
|
|
74
|
+
/** "Product workflow" or "Product workflow (edited)": the board's own workflow. */
|
|
75
|
+
export function workflowLabel(b) {
|
|
76
|
+
if (b && b.self) return 'PMOS ladder';
|
|
77
|
+
const t = TEMPLATES[b && b.template];
|
|
78
|
+
return t ? t.name + ' workflow' + (b.templateEdited ? ' (edited)' : '') : 'Custom workflow';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ── the location in the URL ── */
|
|
82
|
+
const dec = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
|
|
83
|
+
/** `init=` value -> { view, board, id, tab, filter, tool }. Anything unreadable is the Portfolio. */
|
|
84
|
+
export function parseLoc(s) {
|
|
85
|
+
const p = String(s || '').split(':').map(dec);
|
|
86
|
+
if (p[0] === 'board' && p[1]) return { view: 'board', board: p[1], tab: BOARD_IDS.includes(p[2]) ? p[2] : 'summary', filter: p[3] ? Object.fromEntries(new URLSearchParams(p[3])) : {} };
|
|
87
|
+
if (p[0] === 'ini' && p[1] && p[2]) return { view: 'ini', board: p[1], id: p[2], tab: INI_IDS.includes(p[3]) ? p[3] : 'overview', tool: p[4] || '' };
|
|
88
|
+
return { view: 'portfolio' };
|
|
89
|
+
}
|
|
90
|
+
/** The inverse of parseLoc; '' for the Portfolio. */
|
|
91
|
+
export function formatLoc(loc) {
|
|
92
|
+
const e = encodeURIComponent;
|
|
93
|
+
if (!loc || loc.view === 'portfolio' || !loc.board) return '';
|
|
94
|
+
if (loc.view === 'board') {
|
|
95
|
+
const f = Object.entries(loc.filter || {}).filter(([, v]) => v !== '' && v != null);
|
|
96
|
+
return ['board', e(loc.board), e(loc.tab || 'summary')].join(':') + (f.length ? ':' + e(new URLSearchParams(f).toString()) : '');
|
|
97
|
+
}
|
|
98
|
+
return ['ini', e(loc.board), e(loc.id), e(loc.tab || 'overview')].join(':') + (loc.tool ? ':' + e(loc.tool) : '');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ensureCss() {
|
|
102
|
+
ensureC3Css();
|
|
103
|
+
ensureComponentsCss(); // first, so the reset in c3-initiatives.css comes after the primitive's own rules
|
|
104
|
+
if (typeof document === 'undefined' || document.querySelector('link[data-c3-ini]')) return;
|
|
105
|
+
const l = document.createElement('link');
|
|
106
|
+
l.rel = 'stylesheet'; l.href = new URL('./c3-initiatives.css', import.meta.url).href; l.setAttribute('data-c3-ini', '');
|
|
107
|
+
document.head.appendChild(l);
|
|
108
|
+
}
|
|
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'));
|
|
111
|
+
const errLine = (text) => el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), text);
|
|
112
|
+
const sentenceOf = (bad, warn) => (bad.length || warn.length
|
|
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(', ')
|
|
114
|
+
: 'Every board is on track');
|
|
115
|
+
|
|
116
|
+
/* ════════════════════════════════════════════════════════════════════════════════════════════════════════════════════ */
|
|
117
|
+
|
|
118
|
+
export function mountInitiatives(pane, opts) {
|
|
119
|
+
opts = opts || {};
|
|
120
|
+
ensureCss();
|
|
121
|
+
loadTabModules();
|
|
122
|
+
const P = { loc: parseLoc(new URLSearchParams(location.search).get('init')), boards: null, today: null, seq: 0, dispose: null, ini: null };
|
|
123
|
+
const root = el('div', { class: 'calwrap ini', 'data-ini': 'page' });
|
|
124
|
+
pane.replaceChildren(root);
|
|
125
|
+
|
|
126
|
+
/* ── the api, with the board's ws ── */
|
|
127
|
+
const apiFor = (ws) => ({
|
|
128
|
+
get(path, params) {
|
|
129
|
+
const q = new URLSearchParams();
|
|
130
|
+
if (ws) q.set('ws', ws);
|
|
131
|
+
for (const [k, v] of Object.entries(params || {})) if (v !== '' && v != null) q.set(k, String(v));
|
|
132
|
+
const s = q.toString();
|
|
133
|
+
const url = path + (s ? (path.includes('?') ? '&' : '?') + s : '');
|
|
134
|
+
// A board read answers { pending } while the repository is first read. Ask again every 2 s for up to a minute.
|
|
135
|
+
return (async () => {
|
|
136
|
+
for (let n = 0; ; n++) {
|
|
137
|
+
const r = await pmGet(url);
|
|
138
|
+
if (!(r && r.pending) || n >= 30 || !path.startsWith('/api/boards')) return r;
|
|
139
|
+
await new Promise((res) => setTimeout(res, 2000));
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
},
|
|
143
|
+
post(path, body) { return pmPost(path, { ws, ...(body || {}) }); },
|
|
144
|
+
});
|
|
145
|
+
const pageApi = apiFor(opts.ws || '');
|
|
146
|
+
|
|
147
|
+
function writeUrl() {
|
|
148
|
+
try {
|
|
149
|
+
const u = new URL(location.href); const v = formatLoc(P.loc);
|
|
150
|
+
if (v) u.searchParams.set('init', v); else u.searchParams.delete('init');
|
|
151
|
+
if (u.href !== location.href) history.replaceState(history.state, '', u);
|
|
152
|
+
} catch { /* no history */ }
|
|
153
|
+
}
|
|
154
|
+
function go(loc) {
|
|
155
|
+
const l = { ...(loc || { view: 'portfolio' }) };
|
|
156
|
+
if (l.view === 'board') { l.tab = BOARD_IDS.includes(l.tab) ? l.tab : 'summary'; l.filter = l.filter || {}; }
|
|
157
|
+
if (l.view === 'ini') { l.tab = INI_IDS.includes(l.tab) ? l.tab : 'overview'; l.tool = l.tool || ''; }
|
|
158
|
+
P.loc = l; writeUrl(); draw();
|
|
159
|
+
}
|
|
160
|
+
const openBoard = (id, tab, filter) => go({ view: 'board', board: id, tab: tab || 'summary', filter: filter || {} });
|
|
161
|
+
const refresh = () => { P.boards = null; P.ini = null; dropItems(); draw(); };
|
|
162
|
+
const redraw = () => draw();
|
|
163
|
+
|
|
164
|
+
async function loadBoards() {
|
|
165
|
+
if (P.boards) return P.boards;
|
|
166
|
+
for (let k = 0; k < 20; k++) {
|
|
167
|
+
const r = await pageApi.get('/api/boards');
|
|
168
|
+
if (r && r.ok && r.pending) { status('Reading the repository. The boards show when it is read.'); await new Promise((res) => setTimeout(res, 3000)); continue; }
|
|
169
|
+
if (!r || !r.ok) throw new Error((r && r.error) || 'no answer');
|
|
170
|
+
P.boards = Array.isArray(r.boards) ? r.boards : [];
|
|
171
|
+
P.today = r.today || P.today || isoToday();
|
|
172
|
+
return P.boards;
|
|
173
|
+
}
|
|
174
|
+
throw new Error('the repository is taking long to read. Reload in a moment.');
|
|
175
|
+
}
|
|
176
|
+
const findBoard = (id) => { const all = (P.boards || []).filter((b) => b.id === id); return all.find((b) => b.ws === opts.ws) || all[0] || null; };
|
|
177
|
+
function status(text) { root.replaceChildren(note(text)); root.firstChild.setAttribute('role', 'status'); root.firstChild.setAttribute('data-ini', 'status'); }
|
|
178
|
+
|
|
179
|
+
async function draw() {
|
|
180
|
+
const token = ++P.seq;
|
|
181
|
+
const live = () => token === P.seq;
|
|
182
|
+
if (P.dispose) { const d = P.dispose; P.dispose = null; try { d(); } catch { /* the view cleans its own */ } }
|
|
183
|
+
closeMenu();
|
|
184
|
+
if (!P.boards) status('Reading the boards');
|
|
185
|
+
let boards;
|
|
186
|
+
try { boards = await loadBoards(); } catch (e) { if (live()) root.replaceChildren(errLine('The boards could not be read: ' + e.message)); return; }
|
|
187
|
+
if (!live()) return;
|
|
188
|
+
root.dataset.iniView = P.loc.view;
|
|
189
|
+
if (P.loc.view === 'portfolio') return drawPortfolio(boards, token);
|
|
190
|
+
const b = findBoard(P.loc.board);
|
|
191
|
+
if (!b) { root.replaceChildren(pathCrumbs(null), errLine('No board "' + P.loc.board + '" is in the portfolio. It may have been renamed or removed.')); return; }
|
|
192
|
+
if (P.loc.view === 'board') return drawBoard(b, token);
|
|
193
|
+
return drawIni(b, token);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pathCrumbs(b, row, right) {
|
|
197
|
+
const parts = [{ t: PORTFOLIO, ic: 'cols', go: b ? () => go({ view: 'portfolio' }) : null, attrs: { 'data-ini-crumb': 'portfolio' } }];
|
|
198
|
+
if (b) parts.push({ t: b.name, go: row ? () => openBoard(b.id, 'initiatives') : null, attrs: { 'data-ini-crumb': 'board' } });
|
|
199
|
+
if (row) parts.push({ t: row.id + ' ' + trunc(row.title || '', 44), attrs: { 'data-ini-crumb': 'initiative' } });
|
|
200
|
+
return crumbs(parts, right);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ── the ctx a tab is handed ── */
|
|
204
|
+
function makeCtx(b, extra) {
|
|
205
|
+
const ws = b.ws || opts.ws || '';
|
|
206
|
+
const ctx = {
|
|
207
|
+
ws, board: b, today: P.today || isoToday(), row: null, history: [], tools: [], toolsByCat: null, tab: '', filter: {}, tool: '', ...extra,
|
|
208
|
+
api: apiFor(ws), go,
|
|
209
|
+
openInitiative: (id, tab, board) => go({ view: 'ini', board: board || b.id, id, tab: tab || 'overview' }),
|
|
210
|
+
openBoard, toast, openSheet, refresh, redraw, demo: !!opts.demo, showTab: typeof opts.showTab === 'function' ? opts.showTab : () => {},
|
|
211
|
+
moveTo: (id, stage) => moveTo(b, id, stage),
|
|
212
|
+
openWorkflow: (row) => openWorkflow(b, row),
|
|
213
|
+
openBoardSettings: () => openBoardSettings(b),
|
|
214
|
+
newInitiative: () => openNewIni(b),
|
|
215
|
+
};
|
|
216
|
+
return Object.freeze(ctx);
|
|
217
|
+
}
|
|
218
|
+
async function mountTab(host, mount, label, ctx, token) {
|
|
219
|
+
await loadTabModules();
|
|
220
|
+
if (token !== P.seq) return;
|
|
221
|
+
const m = (ctx.row ? INITIATIVE_TABS : BOARD_TABS)[ctx.tab] || mount;
|
|
222
|
+
if (typeof m !== 'function') { host.replaceChildren(note('The ' + label + ' view is not built yet.')); return; }
|
|
223
|
+
try {
|
|
224
|
+
const d = await m(host, ctx);
|
|
225
|
+
if (typeof d === 'function') { if (token === P.seq) P.dispose = d; else d(); }
|
|
226
|
+
} catch (e) {
|
|
227
|
+
if (token === P.seq) host.replaceChildren(errLine('The ' + label + ' view could not draw: ' + String((e && e.message) || e)));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ════════ PORTFOLIO ════════ */
|
|
232
|
+
async function drawPortfolio(boards, token) {
|
|
233
|
+
const asks = boards.reduce((n, b) => n + (Number(b.asks) || 0), 0);
|
|
234
|
+
const total = boards.reduce((n, b) => n + ((b.counts && b.counts.total) || 0), 0);
|
|
235
|
+
const bad = boards.filter((b) => b.health === 'bad'); const warn = boards.filter((b) => b.health === 'warn');
|
|
236
|
+
const tlHost = el('div', { 'data-ini': 'pf-timeline' }, note('Reading the work items of each board'));
|
|
237
|
+
const byCatAll = Object.fromEntries(CATS.map((c) => [c, boards.reduce((n, b) => n + (((b.counts || {}).byCat || {})[c] || 0), 0)]));
|
|
238
|
+
const maxCat = Math.max(1, ...CATS.map((c) => byCatAll[c]));
|
|
239
|
+
const askBoards = boards.filter((b) => Number(b.asks) > 0);
|
|
240
|
+
const left = sect({ ic: 'cols', title: 'Boards', count: boards.length },
|
|
241
|
+
boards.length ? list(boards.map(boardRow), { 'data-ini': 'boards' }) : empty('No board yet. Each repository gets one when it is read. Press New board to add one.'),
|
|
242
|
+
note('A board holds initiatives. Each initiative runs its own workflow, and boards that build a product also mirror Jira.'));
|
|
243
|
+
const right = [
|
|
244
|
+
card({ ic: 'flag', title: 'Where the initiatives are', sub: 'across the boards' },
|
|
245
|
+
hbs(CATS.map((c) => el('div', { class: 'ini-c', 'data-cat': c }, hbr([sw(c), ' ', c], byCatAll[c], byCatAll[c] / maxCat, 'acc', { class: 'hbr ini-c', 'data-cat': c })))),
|
|
246
|
+
note('Stages roll up into Discover, Plan, Do and Learn, so boards on different workflows compare.')),
|
|
247
|
+
card({ ic: 'inbox', title: 'Asks waiting for triage' },
|
|
248
|
+
askBoards.length ? chgl(askBoards.map((b) => el('li', null, icon('inbox', 'sm'), lk({ onclick: () => openBoard(b.id, 'triage'), 'data-ini-asks': b.id }, b.name), el('span', { class: 'tag' }, plural(Number(b.asks), 'new ask')))))
|
|
249
|
+
: note('No new asks on any board.')),
|
|
250
|
+
];
|
|
251
|
+
root.replaceChildren(
|
|
252
|
+
pathCrumbs(null, null, el('div', { class: 'chips2' }, mono(plural(boards.length, 'board') + ' · ' + plural(total, 'initiative') + ' · ' + plural(asks, 'new ask')),
|
|
253
|
+
pbtn('primary', 'New board', 'plus', { 'data-ini': 'new-board', onclick: () => openNewBoard() }))),
|
|
254
|
+
el('div', { class: 'calbody', 'data-ini': 'portfolio' },
|
|
255
|
+
lh('cols', sentenceOf(bad, warn), PORTFOLIO + ': boards hold initiatives, initiatives hold epics, epics hold stories, tasks and bugs. Stages roll up into Discover, Plan, Do and Learn so boards compare.'),
|
|
256
|
+
cols(left, right),
|
|
257
|
+
sect({ ic: 'cal', title: 'Timeline across boards', right: mono('Board > initiative > epic') }, tlHost)));
|
|
258
|
+
/* the cross-board timeline: board > the initiatives that are featured, at risk or carry work > their epics */
|
|
259
|
+
const reads = await Promise.all(boards.map((b) => loadItems(makeCtx(b, {})).then((r) => ({ b, r }))));
|
|
260
|
+
if (token !== P.seq) return;
|
|
261
|
+
const rows = []; let more = 0;
|
|
262
|
+
for (const { b, r } of reads) {
|
|
263
|
+
if (!r.ok) continue;
|
|
264
|
+
const ctx = makeCtx(b, {});
|
|
265
|
+
const withWork = new Set(r.items.map((w) => w.ini));
|
|
266
|
+
const now = r.initiatives.filter((i) => i.status !== 'done' && i.start && i.end && (i.horizon === 'now' || withWork.has(i.id) || i.featured));
|
|
267
|
+
const pick = [...now.filter((i) => withWork.has(i.id) || i.featured), ...now.filter((i) => !withWork.has(i.id) && !i.featured && ['at risk', 'late'].includes(i.status)).sort((x, y) => String(x.end).localeCompare(String(y.end))).slice(0, 2)].slice(0, 6);
|
|
268
|
+
more += now.length - pick.length;
|
|
269
|
+
if (!pick.length) continue;
|
|
270
|
+
pick.sort((x, y) => String(x.start).localeCompare(String(y.start)));
|
|
271
|
+
const s = pick.map((i) => i.start).sort()[0]; const e = pick.map((i) => i.end).sort().pop();
|
|
272
|
+
const avg = pick.reduce((n, i) => n + (Number(i.progress) || 0), 0) / pick.length;
|
|
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' },
|
|
275
|
+
kids: pick.map((i) => iniTlRow(ctx, i, workTlRows(ctx, r.items.filter((w) => w.ini === i.id), () => { dropItems(ctx); draw(); }), b)) });
|
|
276
|
+
}
|
|
277
|
+
const { from, to } = tlRange(P.today);
|
|
278
|
+
const node = timeline({ key: 'pf', rows, from, to, today: P.today, markers: [], defOpen: boards.map((b) => 'b:' + b.id), head: 'Board > initiative > epic',
|
|
279
|
+
onMove: (task, days, uid) => { const b = (reads.find(({ r }) => r.items.some((w) => w.ref === task)) || {}).b; if (b) rescheduleTask(makeCtx(b, {}), task, days, () => draw()); },
|
|
280
|
+
onLocked: (k) => lockedToast(makeCtx(boards[0] || {}, {}), k) });
|
|
281
|
+
tlHost.replaceChildren(node, note('Featured, at-risk and active initiatives with dates. ' + (more ? plural(more, 'more is', 'more are') + ' on each board\'s Timeline.' : 'Each board\'s Timeline shows all of its own.')));
|
|
282
|
+
requestAnimationFrame(() => tlInit(tlHost));
|
|
283
|
+
}
|
|
284
|
+
function boardRow(b) {
|
|
285
|
+
const c = b.counts || {}; const by = c.byCat || {}; const tot = CATS.reduce((n, k) => n + (by[k] || 0), 0) || 1;
|
|
286
|
+
const asks = Number(b.asks) || 0; const rel = b.nextRelease;
|
|
287
|
+
const stack = el('div', { class: 'ini-stack', role: 'img', 'aria-label': CATS.map((k) => k + ' ' + (by[k] || 0)).join(', ') },
|
|
288
|
+
...CATS.filter((k) => by[k]).map((k) => geo(el('i', { class: 'ini-c', 'data-cat': k }), { '--in-f': ((by[k] || 0) / tot).toFixed(3) })));
|
|
289
|
+
const after = [stack,
|
|
290
|
+
el('div', { class: 'chips' }, ...CATS.map((k) => chip(sw(k), k + ' ', el('b', null, String(by[k] || 0))))),
|
|
291
|
+
el('div', { class: 'chips' },
|
|
292
|
+
c.risk ? st('warn', 'alert', c.risk + ' at risk') : null, c.late ? st('bad', 'clock', c.late + ' late') : null, !c.risk && !c.late ? chip('No risk flags') : null,
|
|
293
|
+
asks ? lk({ onclick: () => openBoard(b.id, 'triage'), 'data-ini-asks': b.id }, icon('inbox', 'sm'), plural(asks, 'new ask')) : null,
|
|
294
|
+
rel ? chip(icon('out', 'sm'), 'Next release ' + (rel.name || rel.id || '') + (rel.at || rel.date ? ' · ' + day(rel.at || rel.date) : ''), rel.status ? tag(rel.status) : null) : b.build ? chip('No release planned') : null)];
|
|
295
|
+
return nrow({ ic: 'cols', hot: b.health === 'bad' || b.health === 'warn', title: b.name, onOpen: () => openBoard(b.id), openLabel: 'Open ' + b.name, sub: b.full || '',
|
|
296
|
+
chips: [healthSt(b.health), ownerChip(b.owner), b.build ? chip(icon('bolt', 'sm'), 'Build and deliver') : null, buildTag(b),
|
|
297
|
+
lk({ onclick: () => openBoard(b.id, 'initiatives'), 'data-ini-count': b.id }, icon('init', 'sm'), plural(c.total || 0, 'initiative'))],
|
|
298
|
+
after, right: [ib('Open ' + b.name, 'next', { onclick: () => openBoard(b.id), 'data-ini-open-board': b.id })], attrs: { 'data-ini-board': b.id } });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* ════════ BOARD ════════ */
|
|
302
|
+
async function drawBoard(b, token) {
|
|
303
|
+
let tab = P.loc.tab;
|
|
304
|
+
if (BUILD_ONLY.includes(tab) && !b.build) tab = 'summary';
|
|
305
|
+
const ctx0 = makeCtx(b, {});
|
|
306
|
+
const items = await loadItems(ctx0);
|
|
307
|
+
if (token !== P.seq) return;
|
|
308
|
+
const work = items.items; const jiraN = work.filter((w) => w.src === 'jira').length; const pmN = work.length - jiraN;
|
|
309
|
+
const c = b.counts || {}; const asks = Number(b.asks) || 0; const risk = (c.risk || 0) + (c.late || 0);
|
|
310
|
+
const tabs = BOARD_TAB_LIST.filter(([k]) => b.build || !BUILD_ONLY.includes(k)).map(([k, l, ic]) => [k, l, ic,
|
|
311
|
+
k === 'triage' && asks ? el('span', { class: 'bdg' }, String(asks)) : k === 'initiatives' ? el('span', { class: 'bdg g' }, String(c.total || 0)) : k === 'backlog' && items.ok ? el('span', { class: 'bdg g' }, String(work.filter((w) => w.status !== 'Done' && w.kind !== 'Epic').length)) : null]);
|
|
312
|
+
const host = el('div', { class: 'calbody', 'data-ini-panel': tab });
|
|
313
|
+
const def = TEMPLATES[b.defTpl] || TEMPLATES[b.template] || TEMPLATES.product;
|
|
314
|
+
root.replaceChildren(
|
|
315
|
+
pathCrumbs(b, null, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctx0.showTab('rep') }),
|
|
316
|
+
pbtn('outline', 'Board settings', 'settings', { 'data-ini': 'board-settings', 'aria-haspopup': 'dialog', onclick: () => openBoardSettings(b) }))),
|
|
317
|
+
el('div', { class: 'calbody', 'data-ini-board-page': b.id },
|
|
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
|
+
+ 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
|
+
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)]
|
|
322
|
+
: [chip(icon('x', 'sm'), 'Build and deliver off'), chip('No Jira · ' + plural(pmN, 'PMOS task'))]),
|
|
323
|
+
chip(icon('flag', 'sm'), 'New initiatives start with ' + def.name), lk({ onclick: () => openBoardSettings(b), 'data-ini': 'setup-change' }, 'Change')),
|
|
324
|
+
b.self ? note(SELF_WHY) : null,
|
|
325
|
+
segTabs(tabs, tab, (k) => openBoard(b.id, k), 'Board views', { 'data-ini': 'board-tabs' }),
|
|
326
|
+
host));
|
|
327
|
+
mountTab(host, tab === 'summary' ? mountSummary : null, (BOARD_TAB_LIST.find((t) => t[0] === tab) || [])[1], makeCtx(b, { tab, filter: P.loc.filter || {} }), token);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* Summary: initiatives at a glance, where they stand, what needs attention */
|
|
331
|
+
async function mountSummary(host, ctx) {
|
|
332
|
+
const b = ctx.board;
|
|
333
|
+
const r = await loadItems(ctx);
|
|
334
|
+
if (!r.ok) { host.replaceChildren(errLine('The board could not be read: ' + r.error + '.')); return; }
|
|
335
|
+
const inis = r.initiatives; const work = r.items; const open = inis.filter((i) => i.status !== 'done');
|
|
336
|
+
const risk = inis.filter((i) => ['at risk', 'late'].includes(i.status)).sort((x, y) => String(x.end || '9').localeCompare(String(y.end || '9')));
|
|
337
|
+
const withWork = new Set(work.map((w) => w.ini));
|
|
338
|
+
const glance = open.filter((i) => withWork.has(i.id) || i.featured || (i.horizon === 'now' && i.status !== 'on track'))
|
|
339
|
+
.sort((x, y) => (withWork.has(y.id) - withWork.has(x.id)) || ((y.featured ? 1 : 0) - (x.featured ? 1 : 0)) || String(x.end || '9').localeCompare(String(y.end || '9'))).slice(0, 6);
|
|
340
|
+
const epicsOf = (id) => work.filter((w) => w.ini === id && w.kind === 'Epic').length;
|
|
341
|
+
const iniGlance = (i) => nrow({ ic: i.status === 'late' || i.status === 'at risk' ? 'alert' : 'init', hot: i.status === 'late' || i.status === 'at risk', title: i.title, onOpen: () => ctx.openInitiative(i.id), openLabel: 'Open ' + i.id,
|
|
342
|
+
chips: [chipMono(i.id), stepsMini(wfOf(i, b), i.stage, tplName(i, b)), statusChip(i.status), progChip(i.progress || 0, toneOf(i)), ownerChip(i.owner), dateChip(i.start, i.end, i.status !== 'done' && i.end && daysBetween(ctx.today, i.end) < 0), chip(icon('bolt', 'sm'), plural(epicsOf(i.id), 'epic'))],
|
|
343
|
+
right: [ib('Open ' + i.id, 'next', { onclick: () => ctx.openInitiative(i.id) })], attrs: { 'data-ini-row': i.id } });
|
|
344
|
+
const att = risk.slice(0, 5).map((i) => nrow({ ic: 'alert', hot: true, title: i.id + ' ' + i.title, onOpen: () => ctx.openInitiative(i.id),
|
|
345
|
+
chips: [statusChip(i.status), stageChip(i.stage, catOf(wfOf(i, b), i.stage)), ownerChip(i.owner), chip(icon('cal', 'sm'), i.end ? 'ends ' + day(i.end) : 'No end date')], attrs: { 'data-ini-risk': i.id } }));
|
|
346
|
+
const byCat = (c) => open.filter((i) => catOf(wfOf(i, b), i.stage) === c).length; const maxN = Math.max(1, ...CATS.map(byCat));
|
|
347
|
+
const items = work.filter((w) => w.kind !== 'Epic'); const tot = Math.max(1, items.length); const nSt = (s) => items.filter((w) => w.status === s).length;
|
|
348
|
+
const goIni = (filter) => ctx.openBoard(b.id, 'initiatives', filter);
|
|
349
|
+
const rel = b.build && b.nextRelease;
|
|
350
|
+
host.replaceChildren(cols(
|
|
351
|
+
[sect({ ic: 'init', title: 'Initiatives at a glance', count: open.length, right: lk({ onclick: () => goIni({ view: 'all' }), 'data-ini': 'all-initiatives' }, 'All ' + plural(inis.length, 'initiative')) },
|
|
352
|
+
glance.length ? list(glance.map(iniGlance), { 'data-ini': 'glance' }) : empty('No open initiatives yet. Accept an ask in Triage to start one.')),
|
|
353
|
+
sect({ ic: 'alert', title: 'Needs attention', count: risk.length, right: risk.length > att.length ? lk({ onclick: () => goIni({ view: 'risk' }) }, 'See all ' + risk.length) : null },
|
|
354
|
+
att.length ? list(att) : empty('Nothing at risk.'))],
|
|
355
|
+
[card({ ic: 'flag', title: 'Where the initiatives are', sub: 'each on its own workflow' },
|
|
356
|
+
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 })))),
|
|
357
|
+
note('Stages roll up into four categories. Press one to list its initiatives.')),
|
|
358
|
+
card({ ic: 'todo', title: 'Work items', sub: b.build ? 'Jira ' + (b.jira || '') + ' and PMOS' : 'PMOS tasks' },
|
|
359
|
+
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'))),
|
|
360
|
+
el('div', { class: 'chips2' }, lk({ onclick: () => ctx.openBoard(b.id, 'board') }, 'Open the Board'), lk({ onclick: () => ctx.openBoard(b.id, 'timeline') }, 'Open the Timeline'))),
|
|
361
|
+
card({ ic: 'report', title: 'At a glance' }, chgl([
|
|
362
|
+
el('li', null, icon('bolt', 'sm'), lk({ onclick: () => goIni({ view: 'all', cat: 'Do' }) }, 'Open initiatives in Do'), tag(String(byCat('Do')))),
|
|
363
|
+
el('li', null, icon('alert', 'sm'), lk({ onclick: () => goIni({ view: 'risk' }) }, 'At risk or late'), tag(String(risk.length))),
|
|
364
|
+
el('li', null, icon('user', 'sm'), lk({ onclick: () => goIni({ view: 'noowner' }) }, 'No owner'), tag(String(open.filter((i) => !i.owner).length))),
|
|
365
|
+
el('li', null, icon('inbox', 'sm'), lk({ onclick: () => ctx.openBoard(b.id, 'triage') }, 'New asks'), tag(String(Number(b.asks) || 0)))], { 'data-ini': 'at-a-glance' })),
|
|
366
|
+
card({ ic: 'report', title: 'Board dashboard', sub: 'in Reports' },
|
|
367
|
+
rel ? note('Next release: ' + (rel.name || rel.id) + (rel.at || rel.date ? ' · ' + day(rel.at || rel.date) : '')) : null,
|
|
368
|
+
el('div', { class: 'chips2' }, pbtn('outline', 'Open the ' + b.name + ' dashboard', 'next', { onclick: () => ctx.showTab('rep') })))]));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* ════════ BOARD SETTINGS, NEW BOARD, NEW INITIATIVE ════════ */
|
|
372
|
+
const checkbox = (on, label, attrs, onchange) => el('label', { class: 'ini-chk' }, el('input', { type: 'checkbox', checked: !!on, ...(attrs || {}), onchange }), el('span', null, ...[].concat(label)));
|
|
373
|
+
const flowOf = (stages) => { const out = []; stages.forEach((s, k) => { if (k) out.push(icon('next', 'sm')); out.push(stageChip(s.name, s.cat)); }); return el('div', { class: 'chips2' }, ...out); };
|
|
374
|
+
const tplCase = (k) => el('div', { class: 'case', 'data-ini': 'tpl-case' }, el('p', { class: 'sit' }, TEMPLATES[k].use), flowOf(TEMPLATES[k].stages));
|
|
375
|
+
function tplSeg(allowed, cur, onPick, label, extra) {
|
|
376
|
+
return el('div', { class: 'seg ini-seg', role: 'group', 'aria-label': label },
|
|
377
|
+
...Object.keys(TEMPLATES).map((k) => btn({ 'aria-pressed': String(cur === k), 'data-ini-tpl': k, disabled: !allowed.includes(k), title: allowed.includes(k) ? TEMPLATES[k].use : templateBuilds(k) ? 'Needs Build and deliver' : 'Not offered', onclick: () => onPick(k) },
|
|
378
|
+
TEMPLATES[k].name, extra && extra === k ? el('span', { class: 'dim' }, ' default') : null)));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function openBoardSettings(b) {
|
|
382
|
+
if (b.self) { openSheet({ title: 'Board settings · ' + b.name, icon: 'lock', body: el('div', { class: 'ini-form' }, note(SELF_WHY)) }); return; }
|
|
383
|
+
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 host = el('div', { class: 'ini-form', 'data-ini': 'settings-form' });
|
|
385
|
+
const sh = openSheet({ title: 'Board settings · ' + b.name, icon: 'cols', body: host, wide: true });
|
|
386
|
+
let usage = null;
|
|
387
|
+
loadItems(makeCtx(b, {})).then((r) => { usage = r; render(); });
|
|
388
|
+
function render(focusSel) {
|
|
389
|
+
const allowed = (k) => B.build || !templateBuilds(k);
|
|
390
|
+
for (const k of [...B.offer]) if (!allowed(k)) B.offer.delete(k);
|
|
391
|
+
if (!B.offer.size) B.offer.add(Object.keys(TEMPLATES).find(allowed));
|
|
392
|
+
if (!B.offer.has(B.def)) B.def = [...B.offer][0];
|
|
393
|
+
const inis = usage && usage.ok ? usage.initiatives : [];
|
|
394
|
+
const jiraN = usage && usage.ok ? usage.items.filter((w) => w.src === 'jira').length : 0;
|
|
395
|
+
const usingBuild = inis.filter((i) => wfOf(i, b).some(isBuildStage)).length;
|
|
396
|
+
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
|
+
host.replaceChildren(
|
|
398
|
+
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.'),
|
|
399
|
+
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"]'); }),
|
|
400
|
+
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
|
+
: note('Off: Backlog, Board and Timeline show PMOS tasks only. No Sprints or Releases tab, and initiatives choose workflows without Build and Deliver.'),
|
|
402
|
+
!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,
|
|
403
|
+
lbl('WORKFLOWS THIS BOARD OFFERS'),
|
|
404
|
+
chgl(Object.keys(TEMPLATES).map((k) => el('li', null,
|
|
405
|
+
checkbox(B.offer.has(k), [el('b', null, TEMPLATES[k].name), ' ', el('span', { class: 'dim' }, TEMPLATES[k].stages.map((s) => s.name).join(' > '))], { 'data-ini-offer': k, disabled: !allowed(k) },
|
|
406
|
+
(e) => { if (e.target.checked) B.offer.add(k); else if (B.offer.size > 1) B.offer.delete(k); render('[data-ini-offer="' + k + '"]'); }),
|
|
407
|
+
el('span'), tag(allowed(k) ? plural(inis.filter((i) => i.template === k).length, 'uses it', 'use it') : 'needs Build and deliver'))), { 'data-ini': 'offer' }),
|
|
408
|
+
lbl('DEFAULT FOR NEW INITIATIVES AND ACCEPTED ASKS'),
|
|
409
|
+
tplSeg([...B.offer], B.def, (k) => { B.def = k; render('[data-ini-tpl="' + k + '"]'); }, 'Default workflow'),
|
|
410
|
+
tplCase(B.def),
|
|
411
|
+
el('div', { class: 'chips2' }, el('span', { class: 'dim ini-note' }, 'Initiatives that do not run their own workflow run the board\'s: ' + workflowLabel(b) + '.'), lk({ 'data-ini': 'edit-board-workflow', onclick: () => openBoardWorkflow(b) }, 'Edit the board\'s workflow')),
|
|
412
|
+
B.err ? errLine(B.err) : null,
|
|
413
|
+
el('div', { class: 'chips2' }, pbtn('primary', B.busy ? 'Saving' : 'Save board settings', null, { 'data-ini': 'settings-save', disabled: B.busy, onclick: save }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
|
|
414
|
+
if (focusSel) { const f = host.querySelector(focusSel); if (f) f.focus(); }
|
|
415
|
+
}
|
|
416
|
+
async function save() {
|
|
417
|
+
const key = B.jira.trim();
|
|
418
|
+
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
|
+
B.busy = true; B.err = ''; render();
|
|
420
|
+
const body = { board: b.id, buildDeliver: B.build, offer: [...B.offer], defTpl: B.def };
|
|
421
|
+
if (B.build && key) body.jira = key;
|
|
422
|
+
const r = await apiFor(b.ws).post('/api/boards/settings', body);
|
|
423
|
+
B.busy = false;
|
|
424
|
+
if (!r || !r.ok) { B.err = 'The settings were not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
|
|
425
|
+
sh.close();
|
|
426
|
+
const nb = r.board || {};
|
|
427
|
+
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
|
+
refresh();
|
|
429
|
+
}
|
|
430
|
+
render();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function openNewBoard() {
|
|
434
|
+
const wsList = (opts.workspaces || []).filter((w) => w && w.id);
|
|
435
|
+
const N = { name: '', build: true, jira: '', tpl: 'product', ws: opts.ws || (wsList[0] && wsList[0].id) || '', err: '', busy: false };
|
|
436
|
+
const host = el('div', { class: 'ini-form', 'data-ini': 'new-board-form' });
|
|
437
|
+
const sh = openSheet({ title: 'New board', icon: 'plus', body: host, wide: true });
|
|
438
|
+
const name = el('input', { type: 'text', placeholder: 'For example: Partner portal', 'aria-label': 'Board name', 'data-ini': 'nb-name', oninput: (e) => { N.name = e.target.value; } });
|
|
439
|
+
function render(focusSel) {
|
|
440
|
+
const allowed = Object.keys(TEMPLATES).filter((k) => N.build || !templateBuilds(k));
|
|
441
|
+
if (!allowed.includes(N.tpl)) N.tpl = allowed[0];
|
|
442
|
+
host.replaceChildren(
|
|
443
|
+
note('A board sits in the ' + PORTFOLIO + ' and holds initiatives. Each initiative picks its own workflow; the board only offers templates and a default.'),
|
|
444
|
+
lbl('BOARD NAME'), el('div', { class: 'form' }, name),
|
|
445
|
+
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,
|
|
446
|
+
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"]'); }),
|
|
447
|
+
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
|
+
note('Epics, stories, tasks and bugs from this project show next to PMOS tasks. Sprints and releases are mirrored, read-only.'))
|
|
449
|
+
: 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.'),
|
|
450
|
+
lbl('DEFAULT WORKFLOW FOR NEW INITIATIVES'), tplSeg(allowed, N.tpl, (k) => { N.tpl = k; render('[data-ini-tpl="' + k + '"]'); }, 'Default workflow'), tplCase(N.tpl),
|
|
451
|
+
note('Any initiative can switch to another offered workflow, or edit its own stages, from the initiative page.'),
|
|
452
|
+
N.err ? errLine(N.err) : null,
|
|
453
|
+
el('div', { class: 'chips2' }, pbtn('primary', N.busy ? 'Creating' : 'Create board', null, { 'data-ini': 'nb-create', disabled: N.busy, onclick: create }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
|
|
454
|
+
if (focusSel) { const f = host.querySelector(focusSel); if (f) f.focus(); }
|
|
455
|
+
}
|
|
456
|
+
async function create() {
|
|
457
|
+
const n = N.name.trim();
|
|
458
|
+
if (!n) { N.err = 'Give the board a name.'; render(); name.focus(); return; }
|
|
459
|
+
const kind = TEMPLATES[N.tpl].kind;
|
|
460
|
+
const template = kind === 'simple' ? 'simple' : kind === 'project' || !N.build ? 'project' : 'product';
|
|
461
|
+
const body = { name: n, template, buildDeliver: N.build, offer: Object.keys(TEMPLATES).filter((k) => N.build || !templateBuilds(k)), defTpl: N.tpl };
|
|
462
|
+
if (N.build && N.jira.trim()) body.jira = N.jira.trim();
|
|
463
|
+
N.busy = true; N.err = ''; render();
|
|
464
|
+
const r = await apiFor(N.ws).post('/api/boards/create', body);
|
|
465
|
+
N.busy = false;
|
|
466
|
+
if (!r || !r.ok) { N.err = 'The board was not created: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
|
|
467
|
+
sh.close();
|
|
468
|
+
toast('Board ' + n + ' created' + (N.build ? (body.jira ? ', mirroring Jira ' + body.jira.toUpperCase() : ', Build and deliver on') : ', PMOS tasks only') + '. New initiatives start with ' + TEMPLATES[N.tpl].name + '.');
|
|
469
|
+
P.boards = null;
|
|
470
|
+
go({ view: 'board', board: (r.board && r.board.id) || n, tab: 'triage' });
|
|
471
|
+
}
|
|
472
|
+
render();
|
|
473
|
+
name.focus();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* New initiative: the server creates an initiative only by accepting an ask, so this sheet adds the ask and accepts it at once. */
|
|
477
|
+
function openNewIni(b) {
|
|
478
|
+
const offer = offered(b);
|
|
479
|
+
const F = { title: '', anchor: '', lane: 'feature', tpl: offer.includes(b.defTpl) ? b.defTpl : offer[0], err: '', busy: false };
|
|
480
|
+
const host = el('div', { class: 'ini-form', 'data-ini': 'new-ini-form' });
|
|
481
|
+
const sh = openSheet({ title: 'New initiative · ' + b.name, icon: 'plus', body: host, wide: true });
|
|
482
|
+
const title = el('input', { type: 'text', 'aria-label': 'Initiative title', placeholder: 'e.g. Enable passwordless login with email OTP', 'data-ini': 'ni-title', oninput: (e) => { F.title = e.target.value; } });
|
|
483
|
+
const anchor = el('input', { type: 'text', 'aria-label': 'Anchor', placeholder: 'A key result or a health budget, e.g. KR 2.1 Activation to 45%', 'data-ini': 'ni-anchor', oninput: (e) => { F.anchor = e.target.value; } });
|
|
484
|
+
function render(focusSel) {
|
|
485
|
+
host.replaceChildren(
|
|
486
|
+
note('Lives in ' + PORTFOLIO + ' > ' + b.name + '. It gets its own workflow: pick one of the templates this board offers. You can edit its stages later. It is recorded as an ask you accepted.'),
|
|
487
|
+
lbl('TITLE'), el('div', { class: 'form' }, title),
|
|
488
|
+
lbl('ANCHOR'), el('div', { class: 'form' }, anchor), note('Every initiative anchors to a key result or a health budget. PMOS refuses one without it.'),
|
|
489
|
+
el('label', { class: 'ini-field' }, lbl('LANE'), el('select', { 'aria-label': 'Lane', 'data-ini': 'ni-lane', onchange: (e) => { F.lane = e.target.value; } }, ...['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'].map((l) => el('option', { value: l, selected: l === F.lane }, l)))),
|
|
490
|
+
lbl('WORKFLOW'), tplSeg(offer, F.tpl, (k) => { F.tpl = k; render('[data-ini-tpl="' + k + '"]'); }, 'Workflow', b.defTpl), tplCase(F.tpl),
|
|
491
|
+
b.build ? null : note(b.name + ' does not build a product, so workflows with Build and Deliver are not offered.'),
|
|
492
|
+
F.err ? errLine(F.err) : null,
|
|
493
|
+
el('div', { class: 'chips2' }, pbtn('primary', F.busy ? 'Creating' : 'Create initiative', null, { 'data-ini': 'ni-create', disabled: F.busy, onclick: create }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
|
|
494
|
+
if (focusSel) { const f = host.querySelector(focusSel); if (f) f.focus(); }
|
|
495
|
+
}
|
|
496
|
+
async function create() {
|
|
497
|
+
const t = F.title.trim(); const a = F.anchor.trim();
|
|
498
|
+
if (!t || !a) { F.err = !t ? 'Give the initiative a title.' : 'Give it an anchor: a key result or a health budget.'; render(); (t ? anchor : title).focus(); return; }
|
|
499
|
+
const api = apiFor(b.ws);
|
|
500
|
+
F.busy = true; F.err = ''; render();
|
|
501
|
+
const add = await api.post('/api/boards/asks/add', { board: b.id, line: t, source: 'New initiative, typed by you' });
|
|
502
|
+
const askId = add && add.ok && add.ask && add.ask.id;
|
|
503
|
+
const acc = askId ? await api.post('/api/boards/asks/accept', { board: b.id, ask: askId, title: t, lane: F.lane, anchor: a }) : add;
|
|
504
|
+
const id = acc && acc.ok && acc.initiative && (acc.initiative.id || acc.initiative);
|
|
505
|
+
if (!id) { F.busy = false; F.err = 'The initiative was not created: ' + ((acc && acc.error) || 'no answer') + '.'; render(); return; }
|
|
506
|
+
if (F.tpl !== b.defTpl) {
|
|
507
|
+
const stages = TEMPLATES[F.tpl].stages.map((s) => ({ name: s.name, cat: s.cat }));
|
|
508
|
+
await api.post('/api/boards/ini-workflow', { board: b.id, id, template: F.tpl, stages, moveTo: stages[0].name });
|
|
509
|
+
}
|
|
510
|
+
sh.close();
|
|
511
|
+
toast(id + ' created with the ' + TEMPLATES[F.tpl].name + ' workflow.');
|
|
512
|
+
P.boards = null; dropItems();
|
|
513
|
+
go({ view: 'ini', board: b.id, id, tab: 'overview' });
|
|
514
|
+
}
|
|
515
|
+
render();
|
|
516
|
+
title.focus();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* ════════ THE WORKFLOW EDITOR: one initiative's stages, or the board's own ════════ */
|
|
520
|
+
function openWorkflow(b, row) {
|
|
521
|
+
if (b.self) { openSheet({ title: 'Workflow · ' + row.id, icon: 'lock', body: el('div', { class: 'ini-form' }, note(SELF_WHY)) }); return; }
|
|
522
|
+
const offer = offered(b);
|
|
523
|
+
const now = wfOf(row, b);
|
|
524
|
+
let seq = 0;
|
|
525
|
+
const W = { tpl: row.template || null, draft: now.map((s) => ({ key: 'k' + (++seq), name: s.name, cat: s.cat, was: s.name })), confirm: false, err: '', busy: false };
|
|
526
|
+
const host = el('div', { class: 'ini-form', 'data-ini': 'wf-editor' });
|
|
527
|
+
const sh = openSheet({ title: 'Workflow · ' + row.id, icon: 'cols', body: host, wide: true });
|
|
528
|
+
const mv = () => {
|
|
529
|
+
const kept = new Map(W.draft.filter((d) => d.was).map((d) => [d.was, d.name.trim()]));
|
|
530
|
+
if (kept.has(row.stage)) return { to: kept.get(row.stage), removed: false };
|
|
531
|
+
const k = now.findIndex((s) => s.name === row.stage);
|
|
532
|
+
for (let j = k - 1; j >= 0; j--) if (kept.has(now[j].name)) return { to: kept.get(now[j].name), removed: true };
|
|
533
|
+
const same = W.draft.find((d) => d.cat === catOf(now, row.stage));
|
|
534
|
+
return { to: ((same || W.draft[0] || {}).name || '').trim(), removed: true };
|
|
535
|
+
};
|
|
536
|
+
const rowsUi = stageRows(W, { current: row.stage, build: b.build !== false, onChange: () => { W.confirm = false; render(); } });
|
|
537
|
+
function render(focusSel) {
|
|
538
|
+
const m = mv();
|
|
539
|
+
host.replaceChildren(
|
|
540
|
+
note('The ordered stages of ' + row.id + ' only. Other initiatives on ' + b.name + ' keep their own. Each stage sits in one category so the portfolio can compare. No conditions or validators, by design: moving work is your call.'),
|
|
541
|
+
el('div', { class: 'chips2', role: 'group', 'aria-label': 'Start from a template' }, mono('Start from a template ' + b.name + ' offers'),
|
|
542
|
+
...Object.keys(TEMPLATES).map((k) => pill({ 'aria-pressed': String(W.tpl === k), 'data-wf-tpl': k, disabled: !offer.includes(k), title: offer.includes(k) ? TEMPLATES[k].use : templateBuilds(k) && b.build === false ? 'Needs Build and deliver on the board' : 'Not offered on this board',
|
|
543
|
+
onclick: () => { W.tpl = k; W.draft = TEMPLATES[k].stages.map((s) => ({ key: 'k' + (++seq), name: s.name, cat: s.cat, was: now.some((o) => o.name === s.name) ? s.name : null })); W.confirm = false; W.err = ''; render('[data-wf-tpl="' + k + '"]'); } }, TEMPLATES[k].name))),
|
|
544
|
+
b.build === false ? note(b.name + ' has Build and deliver off, so Build and Deliver stages are not offered. Turn it on in the board settings to use them.') : null,
|
|
545
|
+
rowsUi.list(),
|
|
546
|
+
el('div', { class: 'chips2' }, pbtn('outline', 'Add stage', 'plus', { 'data-wf-add': '', onclick: () => { W.draft.push({ key: 'k' + (++seq), name: 'New stage', cat: 'Do', was: null }); W.confirm = false; render(); const ins = host.querySelectorAll('[data-wf-name]'); const last = ins[ins.length - 1]; if (last) { last.focus(); last.select(); } } })),
|
|
547
|
+
m.removed && !W.confirm ? note(row.id + ' is in ' + row.stage + ', which you removed. It moves to ' + (m.to || 'the first stage') + ' when you save.') : null,
|
|
548
|
+
W.err ? errLine(W.err) : null,
|
|
549
|
+
W.confirm ? el('div', { class: 'fold w ini-confirm', role: 'alert', 'data-ini': 'wf-confirm' }, el('b', null, 'Move ' + row.id + ' from ' + row.stage + ' to ' + m.to + ' before saving?'),
|
|
550
|
+
el('span', { class: 'dim' }, 'The move is recorded in the initiative\'s history as your act.'),
|
|
551
|
+
el('div', { class: 'chips2' }, pbtn('primary', 'Move it and save', null, { 'data-wf-confirm': '', onclick: () => save(true) }), pbtn('outline', 'Keep editing', null, { 'data-wf-back': '', onclick: () => { W.confirm = false; render(); } }))) : null,
|
|
552
|
+
el('div', { class: 'chips2' }, pbtn('primary', W.busy ? 'Saving' : 'Save workflow', null, { 'data-wf-save': '', disabled: W.busy, onclick: () => save(false) }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
|
|
553
|
+
if (focusSel) { const f = host.querySelector(focusSel); if (f) f.focus(); }
|
|
554
|
+
if (W.confirm) { const c = host.querySelector('[data-ini="wf-confirm"]'); if (c && c.scrollIntoView) c.scrollIntoView({ block: 'nearest' }); }
|
|
555
|
+
}
|
|
556
|
+
rowsUi.render = render;
|
|
557
|
+
async function save(force) {
|
|
558
|
+
const names = W.draft.map((d) => d.name.trim());
|
|
559
|
+
W.err = names.some((n) => !n) ? 'Every stage needs a name.' : new Set(names.map((n) => n.toLowerCase())).size !== names.length ? 'Two stages have the same name.'
|
|
560
|
+
: b.build === false && W.draft.some(isBuildStage) ? 'Build and Deliver need a board with Build and deliver on. ' + b.name + ' has it off.' : '';
|
|
561
|
+
if (W.err) { W.confirm = false; render(); return; }
|
|
562
|
+
const m = mv();
|
|
563
|
+
if (m.removed && !force) { W.confirm = true; render(); return; }
|
|
564
|
+
W.busy = true; render();
|
|
565
|
+
const r = await apiFor(b.ws).post('/api/boards/ini-workflow', { board: b.id, id: row.id, template: W.tpl || undefined, stages: W.draft.map((d) => ({ name: d.name.trim(), cat: d.cat, was: d.was || undefined })), moveTo: m.removed ? m.to : undefined });
|
|
566
|
+
W.busy = false;
|
|
567
|
+
if (!r || !r.ok) { W.err = 'The workflow was not saved: ' + ((r && r.error) || 'no answer') + '.'; W.confirm = false; render(); return; }
|
|
568
|
+
sh.close();
|
|
569
|
+
toast('Workflow saved for ' + row.id + (r.moved ? '. Moved to ' + r.moved.to : '') + '.');
|
|
570
|
+
P.ini = null; P.boards = null; dropItems(); draw();
|
|
571
|
+
}
|
|
572
|
+
render('[data-wf-name="0"]');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The stage rows of an editor: grip (drag to reorder), name, category, chips, up, down, remove. o.render is set by the caller. */
|
|
576
|
+
function stageRows(W, o) {
|
|
577
|
+
let dragFrom = null;
|
|
578
|
+
const api = { render: () => {} };
|
|
579
|
+
api.list = () => list(W.draft.map((s, k) => {
|
|
580
|
+
const grip = el('span', { class: 'ki ini-grip', draggable: 'true', 'data-wf-drag': String(k), title: 'Drag to reorder', 'aria-hidden': 'true' }, icon('grip'));
|
|
581
|
+
grip.addEventListener('dragstart', (e) => { dragFrom = k; try { e.dataTransfer.setData('text/plain', 'wf'); e.dataTransfer.effectAllowed = 'move'; const r = grip.closest('[data-wf-row]'); if (r) e.dataTransfer.setDragImage(r, 10, 10); } catch { /* no data transfer */ } });
|
|
582
|
+
const li = el('li', { class: 'nrow', 'data-wf-row': String(k) }, grip,
|
|
583
|
+
el('div', { class: 'nb' },
|
|
584
|
+
el('div', { class: 'form' },
|
|
585
|
+
el('input', { type: 'text', value: s.name, 'aria-label': 'Stage ' + (k + 1) + ' name', 'data-wf-name': String(k), oninput: (e) => { s.name = e.target.value; } }),
|
|
586
|
+
el('select', { 'aria-label': 'Stage ' + (k + 1) + ' category', 'data-wf-cat': String(k), onchange: (e) => { s.cat = e.target.value; o.onChange(); } }, ...CATS.map((c) => el('option', { value: c, selected: c === s.cat }, c)))),
|
|
587
|
+
el('div', { class: 'chips' }, chip(sw(s.cat), s.cat), o.current && s.was === o.current ? st('ok', null, 'current stage') : null,
|
|
588
|
+
s.was ? (s.was !== s.name.trim() ? chip('was ' + s.was) : null) : tag('new'),
|
|
589
|
+
o.counts && s.was ? chip(plural(Number(o.counts[s.was]) || 0, 'initiative')) : null,
|
|
590
|
+
!o.build && isBuildStage(s) ? st('warn', 'alert', 'needs Build and deliver') : null)),
|
|
591
|
+
el('div', { class: 'acts' },
|
|
592
|
+
ib('Move ' + s.name + ' up', el('span', { class: 'ini-up' }, icon('next', 'sm')), { 'data-wf-up': String(k), disabled: k === 0, onclick: () => { [W.draft[k - 1], W.draft[k]] = [W.draft[k], W.draft[k - 1]]; o.onChange(); focusIn('[data-wf-up="' + (k - 1) + '"]', '[data-wf-down="' + (k - 1) + '"]'); } }),
|
|
593
|
+
ib('Move ' + s.name + ' down', el('span', { class: 'ini-down' }, icon('next', 'sm')), { 'data-wf-down': String(k), disabled: k === W.draft.length - 1, onclick: () => { [W.draft[k + 1], W.draft[k]] = [W.draft[k], W.draft[k + 1]]; o.onChange(); focusIn('[data-wf-down="' + (k + 1) + '"]', '[data-wf-up="' + (k + 1) + '"]'); } }),
|
|
594
|
+
ib('Remove ' + s.name, 'x', { 'data-wf-del': String(k), disabled: W.draft.length < 2, onclick: () => { if (W.draft.length > 1) W.draft.splice(k, 1); o.onChange(); focusIn('[data-wf-del="' + Math.min(k, W.draft.length - 1) + '"]', '[data-wf-add]'); } })));
|
|
595
|
+
li.addEventListener('dragover', (e) => { if (dragFrom == null) return; e.preventDefault(); li.classList.add('over'); });
|
|
596
|
+
li.addEventListener('dragleave', () => li.classList.remove('over'));
|
|
597
|
+
li.addEventListener('drop', (e) => { if (dragFrom == null) return; e.preventDefault(); const [m] = W.draft.splice(dragFrom, 1); W.draft.splice(k, 0, m); dragFrom = null; o.onChange(); });
|
|
598
|
+
li.addEventListener('dragend', () => { dragFrom = null; li.classList.remove('over'); });
|
|
599
|
+
return li;
|
|
600
|
+
}), { 'data-ini': 'wf-list' });
|
|
601
|
+
const focusIn = (a, b2) => { const sheetEl = document.querySelector('[data-ini="wf-editor"]'); if (!sheetEl) return; const f = sheetEl.querySelector(a); if (f && !f.disabled) f.focus(); else { const g = sheetEl.querySelector(b2); if (g) g.focus(); } };
|
|
602
|
+
return api;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/* The board's own workflow: what an initiative that does not run its own workflow runs. Removing a stage that holds such initiatives asks
|
|
606
|
+
* where they go (POST /api/boards/workflow, moveRemoved). */
|
|
607
|
+
async function openBoardWorkflow(b) {
|
|
608
|
+
if (b.self) return;
|
|
609
|
+
let seq = 0;
|
|
610
|
+
const counts = {};
|
|
611
|
+
const W = { tpl: b.template, draft: (b.workflow || []).map((s) => ({ key: 'k' + (++seq), name: s.name, cat: s.cat, hint: s.hint || '', was: s.name })), targets: {}, err: '', busy: false };
|
|
612
|
+
const host = el('div', { class: 'ini-form', 'data-ini': 'wf-editor', 'data-ini-mode': 'board' });
|
|
613
|
+
const sh = openSheet({ title: 'Board workflow · ' + b.name, icon: 'cols', body: host, wide: true });
|
|
614
|
+
const r0 = await loadItems(makeCtx(b, {}));
|
|
615
|
+
for (const i of r0.initiatives || []) if (!i.template) counts[i.stage] = (counts[i.stage] || 0) + 1;
|
|
616
|
+
const old = (b.workflow || []).map((s) => s.name);
|
|
617
|
+
const removed = () => old.filter((n) => !W.draft.some((d) => d.was === n));
|
|
618
|
+
const target = (n) => { const names = W.draft.map((d) => d.name.trim()); if (names.includes(W.targets[n])) return W.targets[n]; const k = old.indexOf(n); for (let j = k - 1; j >= 0; j--) { const d = W.draft.find((x) => x.was === old[j]); if (d) return d.name.trim(); } return names[0] || ''; };
|
|
619
|
+
const rowsUi = stageRows(W, { build: b.build !== false, counts, onChange: () => render() });
|
|
620
|
+
function render(focusSel) {
|
|
621
|
+
const gone = removed().filter((n) => Number(counts[n]) > 0);
|
|
622
|
+
host.replaceChildren(
|
|
623
|
+
note('The stages that ' + b.name + '\'s initiatives run when they have no workflow of their own. Each initiative can still pick its own from the templates the board offers.'),
|
|
624
|
+
el('div', { class: 'chips2', role: 'group', 'aria-label': 'Start from a template' }, mono('Start from a template'),
|
|
625
|
+
...BOARD_TEMPLATES.map((k) => pill({ 'aria-pressed': String(W.tpl === k), 'data-wf-tpl': k, disabled: b.build === false && templateBuilds(k), onclick: () => { W.tpl = k; W.draft = TEMPLATES[k].stages.map((s) => ({ key: 'k' + (++seq), name: s.name, cat: s.cat, hint: '', was: old.includes(s.name) ? s.name : null })); render('[data-wf-tpl="' + k + '"]'); } }, TEMPLATES[k].name))),
|
|
626
|
+
rowsUi.list(),
|
|
627
|
+
el('div', { class: 'chips2' }, pbtn('outline', 'Add stage', 'plus', { 'data-wf-add': '', onclick: () => { W.draft.push({ key: 'k' + (++seq), name: 'New stage', cat: 'Do', hint: '', was: null }); render('[data-wf-name="' + (W.draft.length - 1) + '"]'); } })),
|
|
628
|
+
gone.length ? el('div', { class: 'fold w ini-confirm', 'data-ini': 'wf-moves' }, el('b', null, 'Where do their initiatives go?'),
|
|
629
|
+
...gone.map((n) => el('label', { class: 'chips2' }, plural(Number(counts[n]), 'initiative') + ' in ' + n + ' move to',
|
|
630
|
+
el('select', { 'data-wf-target': n, 'aria-label': 'Stage for the initiatives of ' + n, onchange: (e) => { W.targets[n] = e.target.value; } }, ...W.draft.map((d) => d.name.trim()).filter(Boolean).map((x) => el('option', { value: x, selected: x === target(n) }, x))))),
|
|
631
|
+
el('span', { class: 'dim' }, 'Each move is recorded in the initiative history as your act.')) : null,
|
|
632
|
+
W.err ? errLine(W.err) : null,
|
|
633
|
+
el('div', { class: 'chips2' }, pbtn('primary', W.busy ? 'Saving' : 'Save workflow', null, { 'data-wf-save': '', disabled: W.busy, onclick: save }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
|
|
634
|
+
if (focusSel) { const f = host.querySelector(focusSel); if (f) f.focus(); }
|
|
635
|
+
}
|
|
636
|
+
async function save() {
|
|
637
|
+
const names = W.draft.map((d) => d.name.trim());
|
|
638
|
+
W.err = names.some((n) => !n) ? 'Every stage needs a name.' : new Set(names.map((n) => n.toLowerCase())).size !== names.length ? 'Two stages have the same name.' : '';
|
|
639
|
+
if (W.err) { render(); return; }
|
|
640
|
+
const moveRemoved = {}; for (const n of removed()) if (Number(counts[n]) > 0) moveRemoved[n] = target(n);
|
|
641
|
+
W.busy = true; render();
|
|
642
|
+
const r = await apiFor(b.ws).post('/api/boards/workflow', { board: b.id, stages: W.draft.map((d) => ({ name: d.name.trim(), cat: d.cat, hint: d.hint || '', was: d.was || undefined })), moveRemoved, template: W.tpl });
|
|
643
|
+
W.busy = false;
|
|
644
|
+
if (!r || !r.ok) { W.err = 'The workflow was not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
|
|
645
|
+
sh.close();
|
|
646
|
+
toast('Board workflow saved.' + (Number(r.moved) ? ' ' + plural(Number(r.moved), 'initiative') + ' moved to a new stage.' : ''));
|
|
647
|
+
refresh();
|
|
648
|
+
}
|
|
649
|
+
render('[data-wf-name="0"]');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/* ════════ INITIATIVE ════════ */
|
|
653
|
+
async function drawIni(b, token) {
|
|
654
|
+
const { id } = P.loc;
|
|
655
|
+
if (!P.ini || P.ini.id !== id || P.ini.board !== b.id) {
|
|
656
|
+
status('Reading ' + id);
|
|
657
|
+
const r = await apiFor(b.ws).get('/api/boards/initiative', { board: b.id, id });
|
|
658
|
+
if (token !== P.seq) return;
|
|
659
|
+
if (!r || !r.ok || !r.row) {
|
|
660
|
+
root.replaceChildren(pathCrumbs(b, { id, title: '' }), errLine(r && r.pending ? 'Reading the repository. Open the page again in a moment.' : 'The initiative could not be read: ' + ((r && r.error) || 'no answer') + '.'));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
P.ini = { id, board: b.id, row: r.row, history: r.history || [], tools: r.tools || [], toolsByCat: r.toolsByCat || null, b: { ...b, ...(r.board || {}), counts: b.counts, asks: b.asks, nextRelease: b.nextRelease } };
|
|
664
|
+
}
|
|
665
|
+
const { row, history, tools, toolsByCat } = P.ini; const bb = P.ini.b;
|
|
666
|
+
let tab = P.loc.tab; if (tab === 'tickets' && !bb.build) tab = 'overview';
|
|
667
|
+
const ctxI = makeCtx(bb, { row, history, tools, toolsByCat, tab, tool: P.loc.tool || '' });
|
|
668
|
+
const items = await loadItems(ctxI);
|
|
669
|
+
if (token !== P.seq) return;
|
|
670
|
+
const work = items.items.filter((w) => w.ini === row.id); const ep = work.filter((w) => w.kind === 'Epic').length;
|
|
671
|
+
const wf = wfOf(row, bb); const idx = wf.findIndex((s) => s.name === row.stage); const cat = row.cat || catOf(wf, row.stage);
|
|
672
|
+
const warnings = Array.isArray(row.warnings) ? row.warnings : [];
|
|
673
|
+
const stepper = el('div', { class: 'calbar' },
|
|
674
|
+
el('div', { class: 'seg ini-seg', role: 'group', 'aria-label': 'Workflow position', 'data-ini': 'stepper' },
|
|
675
|
+
...wf.map((s, k) => btn({ 'aria-pressed': String(k === idx), 'aria-current': k === idx ? 'step' : 'false', 'data-ini-step': s.name, title: bb.self ? SELF_WHY : k === idx ? 'Current stage' : 'Move to ' + s.name, onclick: () => stepTo(s.name) },
|
|
676
|
+
k < idx ? icon('check', 'sm') : sw(s.cat), s.name))),
|
|
677
|
+
el('span', { class: 'chips2' }, mono(tplName(row, bb) + ' workflow · ' + cat + ' · you decide when it moves'),
|
|
678
|
+
pill({ 'data-ini': 'edit-workflow', 'aria-haspopup': 'dialog', onclick: () => openWorkflow(bb, row) }, icon('settings', 'sm'), 'Edit workflow')));
|
|
679
|
+
const kind = kindOf(row, bb);
|
|
680
|
+
const toolList = [...recordToolsFor(kind, cat, bb.build).map((id) => ({ id, label: RECORD_META[id][0], rec: true })), ...(Array.isArray(tools) ? tools : []).map((t) => (typeof t === 'string' ? { id: t } : t)).filter((t) => t && t.id)];
|
|
681
|
+
const moreBtn = pill({ 'data-ini': 'more-tools', 'aria-haspopup': 'menu', 'aria-expanded': 'false', onclick: (e) => moreTools(e.currentTarget, row, bb, cat, kind) }, icon('more', 'sm'), 'More tools');
|
|
682
|
+
const strip = el('div', { class: 'chips2', 'data-ini': 'tools' }, mono('Tools for ' + row.stage),
|
|
683
|
+
...toolList.map((t) => pill({ 'data-ini-tool': t.id, 'aria-pressed': String(P.loc.tool === t.id), onclick: () => pickTool(t.id) }, icon(t.rec ? RECORD_META[t.id][1] : toolIcon(t.id), 'sm'), toolLabel(t))), moreBtn);
|
|
684
|
+
const tabs = INI_TAB_LIST.filter(([k]) => k !== 'tickets' || bb.build).map(([k, l, ic]) => [k, l, ic,
|
|
685
|
+
k === 'breakdown' && work.length ? el('span', { class: 'bdg g' }, String(work.length)) : k === 'tickets' ? el('span', { class: 'bdg g' }, String(work.filter((w) => w.src === 'jira').length)) : null]);
|
|
686
|
+
const host = el('div', { class: 'calbody', 'data-ini-panel': tab });
|
|
687
|
+
root.replaceChildren(
|
|
688
|
+
pathCrumbs(bb, row, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctxI.showTab('rep') }))),
|
|
689
|
+
el('div', { class: 'calbody', 'data-ini-page': row.id },
|
|
690
|
+
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(' · ')),
|
|
691
|
+
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),
|
|
692
|
+
row.lane ? tag(row.lane) : null, el('span', { class: 'chips2', 'data-ini': 'warnings', 'aria-live': 'polite' }, ...warnings.map(warnChip))),
|
|
693
|
+
stepper, bb.self ? note(SELF_WHY) : null, strip,
|
|
694
|
+
segTabs(tabs, tab, (k) => go({ view: 'ini', board: bb.id, id: row.id, tab: k }), 'Initiative views', { 'data-ini': 'ini-tabs' }),
|
|
695
|
+
host));
|
|
696
|
+
if (P.focusStep) { const f = stepper.querySelector('[data-ini-step="' + CSS.escape(P.focusStep) + '"]'); P.focusStep = null; if (f) f.focus(); }
|
|
697
|
+
mountTab(host, null, (INI_TAB_LIST.find((t) => t[0] === tab) || [])[1], ctxI, token);
|
|
698
|
+
}
|
|
699
|
+
function pickTool(id) {
|
|
700
|
+
if (P.loc.tab !== 'overview' || P.loc.tool !== id) { go({ ...P.loc, tab: 'overview', tool: id }); return; }
|
|
701
|
+
const n = root.querySelector('[data-ini-tool-card="' + CSS.escape(id) + '"]');
|
|
702
|
+
if (n) { n.scrollIntoView({ behavior: 'smooth', block: 'start' }); n.classList.add('ini-flash'); setTimeout(() => n.classList.remove('ini-flash'), 1400); }
|
|
703
|
+
}
|
|
704
|
+
function moreTools(anchor, row, b, cat, kind) {
|
|
705
|
+
const byCat = (P.ini && P.ini.toolsByCat) || {};
|
|
706
|
+
const recOf = (c) => recordToolsFor(kind, c, b.build).map((id) => ({ id, label: RECORD_META[id][0], rec: true }));
|
|
707
|
+
const seen = new Set([...(byCat[cat] || []), ...recOf(cat)].map((t) => t.id));
|
|
708
|
+
const items = [];
|
|
709
|
+
for (const s of wfOf(row, b)) {
|
|
710
|
+
const ks = [...recOf(s.cat), ...(byCat[s.cat] || [])].filter((t) => !seen.has(t.id)); ks.forEach((t) => seen.add(t.id));
|
|
711
|
+
if (ks.length) items.push({ group: s.name }, ...ks.map((t) => ({ kids: [icon(t.rec ? RECORD_META[t.id][1] : toolIcon(t.id), 'sm'), toolLabel(t)], attrs: { 'data-ini-moretool': t.id }, onPick: () => go({ ...P.loc, tab: 'overview', tool: t.id }) })));
|
|
712
|
+
}
|
|
713
|
+
openMenu(anchor, { label: 'More tools', items: items.length ? items : [{ label: 'No other tools for this workflow.', disabled: true, onPick: () => {} }] });
|
|
714
|
+
}
|
|
715
|
+
async function stepTo(stage) {
|
|
716
|
+
const ini = P.ini; if (!ini) return;
|
|
717
|
+
const r = await moveTo(ini.b, ini.row.id, stage);
|
|
718
|
+
if (r && r.ok) { P.focusStep = stage; draw(); }
|
|
719
|
+
}
|
|
720
|
+
/** Move an initiative to a stage of its own workflow: POST /api/boards/move; warnings are shown, never a block. */
|
|
721
|
+
async function moveTo(b, id, stage) {
|
|
722
|
+
if (b.self) { toast(SELF_WHY); return { ok: false }; }
|
|
723
|
+
const r = await apiFor(b.ws).post('/api/boards/move', { board: b.id, id, to: stage });
|
|
724
|
+
if (!r || !r.ok) { toast({ text: 'The move did not save: ' + ((r && r.error) || 'no answer') + '.', tone: 'error' }); return { ok: false }; }
|
|
725
|
+
const warnings = Array.isArray(r.warnings) ? r.warnings : [];
|
|
726
|
+
toast(id + ' moved to ' + stage + '.' + (warnings.length ? ' Warning: ' + warnings.join(', ') + '. Not blocked.' : ''));
|
|
727
|
+
if (P.ini && P.ini.id === id) { P.ini.row = { ...P.ini.row, ...(r.row || {}), stage, warnings: (r.row && r.row.warnings) || warnings }; P.ini.history = [{ kind: 'move', at: new Date().toISOString(), by: 'You', from: null, to: stage, warnings }, ...P.ini.history]; P.ini = null; }
|
|
728
|
+
P.boards = null; dropItems();
|
|
729
|
+
loadBoards().catch(() => null);
|
|
730
|
+
return { ok: true, row: r.row, warnings };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
writeUrl();
|
|
734
|
+
draw();
|
|
735
|
+
return { go, refresh, get location() { return { ...P.loc }; }, close: () => { closeMenu(); closeSheet(); } };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const chipMono = (t) => el('span', { class: 'chip2 mono' }, t);
|
|
739
|
+
const toneOf = (i) => (i.status === 'late' ? 'bad' : i.status === 'at risk' ? 'warn' : 'ok');
|
|
740
|
+
function isoToday() { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }
|
|
741
|
+
export { ownerText, kv, TOOL_META };
|