create-pmos 0.134.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 +16 -1
- 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-capture.js +9 -1
- package/workspace/lib/notes-final.js +38 -13
- package/workspace/lib/notes-replay.js +125 -0
- package/workspace/lib/notes-screen.js +180 -0
- package/workspace/lib/notes-whisper.js +10 -3
- 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 +55 -9
- 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 +1 -1
- package/workspace/public/pm/components.css +319 -213
- package/workspace/public/pm/console3.css +99 -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 +29 -8
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/public/pm/view-notes.js +46 -9
- 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 +12 -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 +10 -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,240 @@
|
|
|
1
|
+
/* pm/c3-ii-tabs.js — the six tabs of an initiative page (mockup v5, 34-initiatives.js iniOverview, iniBreakdown, iniDocs, iniTickets,
|
|
2
|
+
* iniTimeline, iniHistory). registerInitiativeTab(id, mount(host, ctx)); ctx.row, ctx.history (newest first), ctx.tools, ctx.toolsByCat
|
|
3
|
+
* and ctx.tool come from the page (c3-initiatives.js).
|
|
4
|
+
* Overview Left: a Summary, then one card per tool of the current stage: the stage records (c3-ini-records.js: problem, RICE, launch
|
|
5
|
+
* checklist, burndown, kill conditions, learnings) and the server's stage tools (ideas, PRD, breakdown, tickets, releases,
|
|
6
|
+
* measurements, …) drawn from what PMOS holds. ctx.tool (a tool pressed in the strip or picked in More tools) is drawn
|
|
7
|
+
* first and scrolled to. Right: Details (portfolio, board, workflow with Edit, stage, owner, anchor, horizon, lane), Progress,
|
|
8
|
+
* and links to the board's Backlog, Board and Timeline filtered to this initiative; then its releases.
|
|
9
|
+
* Breakdown its epics and items, Jira and PMOS mixed (loadItems), and "Add a PMOS task" (POST /api/boards/tasks/add, epic optional).
|
|
10
|
+
* Docs the documents linked to it (GET /api/docs/library), c3-ib-docs.js renderDocs.
|
|
11
|
+
* Tickets its Jira items: the counts by status and the rows. Read-only: status and dates are Jira's. (Build and deliver only.)
|
|
12
|
+
* Timeline epic > item on the HTML timeline, its own bar on top; PMOS bars drag to reschedule, Jira bars are locked.
|
|
13
|
+
* History ctx.history: moves, workflow changes, assignments and the accept, with who, when, why and the order warnings.
|
|
14
|
+
* Every tab has an honest empty state and an error line when its read fails. */
|
|
15
|
+
import { el } from './dom.js';
|
|
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';
|
|
18
|
+
import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
19
|
+
import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
|
|
20
|
+
import { loadDocs, renderDocs } from './c3-ib-docs.js';
|
|
21
|
+
import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
|
|
22
|
+
|
|
23
|
+
const errLine = (text) => el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), text);
|
|
24
|
+
const toneOf = (s) => (s === 'late' ? 'bad' : s === 'at risk' ? 'warn' : 'ok');
|
|
25
|
+
|
|
26
|
+
/** Run an async tab and drop its result when the PM has left the tab. */
|
|
27
|
+
function tab(id, build) {
|
|
28
|
+
registerInitiativeTab(id, async (host, ctx) => {
|
|
29
|
+
let dead = false;
|
|
30
|
+
host.replaceChildren(note('Reading'));
|
|
31
|
+
try {
|
|
32
|
+
const nodes = await build(ctx, () => dead, host);
|
|
33
|
+
if (!dead && nodes) host.replaceChildren(...[].concat(nodes).filter(Boolean));
|
|
34
|
+
} catch (e) { if (!dead) host.replaceChildren(errLine('This view could not be drawn: ' + ((e && e.message) || e) + '.')); }
|
|
35
|
+
return () => { dead = true; };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const mine = (ctx, items) => items.filter((w) => w.ini === ctx.row.id);
|
|
39
|
+
|
|
40
|
+
/* ── Overview ── */
|
|
41
|
+
async function toolCard(ctx, t, R, rels) {
|
|
42
|
+
const r = ctx.row; const b = ctx.board;
|
|
43
|
+
const work = mine(ctx, R.items); const its = work.filter((w) => w.kind !== 'Epic'); const epics = work.filter((w) => w.kind === 'Epic');
|
|
44
|
+
const goTab = (k, label) => lk({ onclick: () => ctx.go({ view: 'ini', board: b.id, id: r.id, tab: k }), 'data-ini-goto': k }, icon('next', 'sm'), label);
|
|
45
|
+
const by = (s) => its.filter((w) => w.status === s).length;
|
|
46
|
+
let body;
|
|
47
|
+
switch (t.id) {
|
|
48
|
+
case 'tickets': {
|
|
49
|
+
const tk = its.filter((w) => w.src === 'jira');
|
|
50
|
+
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.')];
|
|
52
|
+
else {
|
|
53
|
+
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
|
+
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')]),
|
|
55
|
+
el('div', { class: 'chips2' }, note(plural(tk.length, 'Jira item') + ' in ' + plural(epics.filter((e) => e.src === 'jira').length, 'epic') + '. Status lives in Jira.'), goTab('tickets', 'See Jira items'))];
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case 'breakdown':
|
|
60
|
+
body = [note(plural(epics.length, 'epic') + ', ' + plural(its.length, 'item') + ': ' + by('Done') + ' done, ' + (by('In progress') + by('In review')) + ' in progress, ' + its.filter((w) => w.blocked).length + ' blocked.'), el('div', { class: 'chips2' }, goTab('breakdown', 'Open breakdown'))];
|
|
61
|
+
break;
|
|
62
|
+
case 'releases':
|
|
63
|
+
body = [rels.length ? 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)) || 'no date')), tag(x.status || 'Planned')))) : note('Not in a release yet.'),
|
|
64
|
+
b.build ? el('div', { class: 'chips2' }, lk({ onclick: () => ctx.openBoard(b.id, 'releases') }, icon('next', 'sm'), b.name + ' Releases')) : null];
|
|
65
|
+
break;
|
|
66
|
+
case 'placement': case 'timeline':
|
|
67
|
+
body = [kv([['Horizon', r.horizon ? r.horizon[0].toUpperCase() + r.horizon.slice(1) : 'Not set'], ['Dates', r.start || r.end ? day(r.start) + ' to ' + day(r.end) : 'No dates yet']]),
|
|
68
|
+
el('div', { class: 'chips2' }, goTab('timeline', 'Open its timeline'), lk({ onclick: () => ctx.openBoard(b.id, 'timeline', { ini: r.id }) }, icon('next', 'sm'), b.name + ' Timeline'))];
|
|
69
|
+
break;
|
|
70
|
+
case 'measurements':
|
|
71
|
+
body = [note('Target: ' + (r.anchor || 'no anchor yet') + '. Measurements show here once it ships and the key result is read.')];
|
|
72
|
+
break;
|
|
73
|
+
case 'evaluation':
|
|
74
|
+
body = [note('Did it move ' + (r.anchor || 'its anchor') + '? The evaluation is written against its done-criteria.'), el('div', { class: 'chips2' }, goTab('docs', 'Open its docs'))];
|
|
75
|
+
break;
|
|
76
|
+
case 'history': {
|
|
77
|
+
const h = (ctx.history || []).slice(0, 3);
|
|
78
|
+
body = [h.length ? chgl(h.map((x) => el('li', null, icon('undo', 'sm'), el('span', null, histWhat(x)), tag(x.at ? ago(String(x.at).slice(0, 10), ctx.today) : '')))) : note('No moves recorded yet.'), el('div', { class: 'chips2' }, goTab('history', 'Open history'))];
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
default: {
|
|
82
|
+
const docs = R.docs.filter((d) => (t.id === 'prd' || t.id === 'brief' ? /spec|prd|brief/i.test(d.cat + ' ' + d.ty) : t.id === 'rubric' ? /eval|rubric/i.test(d.cat + ' ' + d.name) : /res|note|idea|hypo/i.test(d.cat + ' ' + d.name)));
|
|
83
|
+
body = [docs.length ? chgl(docs.slice(0, 4).map((d) => el('li', null, icon('doc', 'sm'), el('span', null, d.name), tag(d.ty)))) : note('Nothing linked yet. ' + ({ ideas: 'Capture ideas once the problem is clear.', hypotheses: 'Say what you believe and how you will know.', notes: 'Keep notes in a document linked to ' + r.id + '.', prd: 'Write the PRD before the build.', rubric: 'Agree the done-criteria with the PRD.', brief: 'A short brief: scope, owner, dates.' }[t.id] || '')),
|
|
84
|
+
el('div', { class: 'chips2' }, goTab('docs', 'Open its docs'))];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return card({ ic: toolIcon(t.id), title: toolLabel(t), attrs: { 'data-ini-tool-card': t.id, id: 'tool-' + t.id } }, ...body.filter(Boolean));
|
|
88
|
+
}
|
|
89
|
+
function histWhat(h) {
|
|
90
|
+
if (h.kind === 'workflow') return 'Changed the workflow to ' + (h.template ? h.template : 'its own') + (Array.isArray(h.stages) && h.stages.length ? ': ' + h.stages.join(' > ') : '');
|
|
91
|
+
if (h.kind === 'assign') return 'Assigned to board ' + (h.to || '');
|
|
92
|
+
if (h.kind === 'ask-accept') return 'Accepted from an ask into ' + (h.stage || h.to || 'the first stage');
|
|
93
|
+
if (h.from || h.to) return 'Moved from ' + (h.from || 'no stage') + ' to ' + (h.to || 'no stage');
|
|
94
|
+
return String(h.kind || 'Change');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
tab('overview', async (ctx, isDead, host) => {
|
|
98
|
+
const r = ctx.row; const b = ctx.board;
|
|
99
|
+
const wf = wfOf(r, b); const cat = r.cat || catOf(wf, r.stage); const kind = kindOf(r, b);
|
|
100
|
+
const [R0, rel] = await Promise.all([loadItems(ctx), b.build ? ctx.api.get('/api/releases', { board: b.id }) : Promise.resolve(null)]);
|
|
101
|
+
const lib = await loadDocs(ctx, new Set([r.id]));
|
|
102
|
+
if (isDead()) return null;
|
|
103
|
+
const R = { items: R0.ok ? R0.items : [], docs: lib.ok ? lib.docs : [] };
|
|
104
|
+
const rels = rel && rel.ok ? (rel.releases || []).filter((x) => Array.isArray(x.initiative_ids) && x.initiative_ids.includes(r.id)) : [];
|
|
105
|
+
const server = (Array.isArray(ctx.tools) ? ctx.tools : []).map((t) => (typeof t === 'string' ? { id: t } : t)).filter((t) => t && t.id);
|
|
106
|
+
let recIds = recordToolsFor(kind, cat, b.build);
|
|
107
|
+
let srv = server.slice();
|
|
108
|
+
const picked = ctx.tool || '';
|
|
109
|
+
if (picked && RECORD_META[picked] && !recIds.includes(picked)) recIds = [picked, ...recIds];
|
|
110
|
+
if (picked && !RECORD_META[picked] && !srv.some((t) => t.id === picked)) {
|
|
111
|
+
const any = Object.values(ctx.toolsByCat || {}).flat().find((t) => t && t.id === picked);
|
|
112
|
+
srv = [any || { id: picked }, ...srv];
|
|
113
|
+
}
|
|
114
|
+
const recs = await recordCards(ctx, recIds, { boardIds: R0.ok ? R0.initiatives.map((i) => i.id) : [] });
|
|
115
|
+
const srvCards = await Promise.all(srv.map((t) => toolCard(ctx, t, R, rels)));
|
|
116
|
+
if (isDead()) return null;
|
|
117
|
+
const toolsAll = [...recs, ...srvCards];
|
|
118
|
+
if (picked) { const k = toolsAll.findIndex((n) => n.getAttribute('data-ini-tool-card') === picked); if (k > 0) toolsAll.unshift(toolsAll.splice(k, 1)[0]); }
|
|
119
|
+
const work = mine(ctx, R.items); const p = Number(r.progress) || 0;
|
|
120
|
+
const left = [
|
|
121
|
+
sect({ ic: 'notes', title: 'Summary' }, el('p', { class: 'ini-p' }, r.summary || ('Serves ' + (r.anchor || 'no anchor yet') + '. ' + (r.lane ? r.lane[0].toUpperCase() + r.lane.slice(1) + ' lane, ' : '') + tplName(r, b) + ' workflow, now in ' + (r.stage || 'no stage') + ' (' + cat + ').')),
|
|
122
|
+
Array.isArray(r.warnings) && r.warnings.length ? el('div', { class: 'chips2' }, ...r.warnings.map((w) => st('warn', 'alert', w, { 'data-ini-ovwarn': w }))) : null),
|
|
123
|
+
...(toolsAll.length ? toolsAll : [empty('No tools for ' + (r.stage || 'this stage') + '.')]),
|
|
124
|
+
];
|
|
125
|
+
const right = [
|
|
126
|
+
card({ ic: 'init', title: 'Details', attrs: { 'data-ini': 'details' } },
|
|
127
|
+
kv([['Portfolio', PORTFOLIO], ['Board', lk({ onclick: () => ctx.openBoard(b.id, 'initiatives') }, b.name)],
|
|
128
|
+
['Workflow', el('span', { class: 'chips2' }, tplName(r, b), lk({ 'data-ini-wf': r.id, onclick: () => ctx.openWorkflow(r) }, 'Edit'))], ['Stage', stageChip(r.stage, cat)], ['Status', statusChip(r.status)],
|
|
129
|
+
['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
|
+
hbs([hbr('Progress', Math.round(p * 100) + '%', p, toneOf(r.status))]),
|
|
131
|
+
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' },
|
|
133
|
+
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
|
+
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
|
+
];
|
|
136
|
+
if (picked) requestAnimationFrame(() => { const n = host.querySelector('[data-ini-tool-card="' + CSS.escape(picked) + '"]'); if (n && !isDead()) { if (n.scrollIntoView) n.scrollIntoView({ block: 'nearest' }); n.classList.add('ini-flash'); setTimeout(() => n.classList.remove('ini-flash'), 1400); } });
|
|
137
|
+
return cols(left, right);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/* ── Breakdown ── */
|
|
141
|
+
tab('breakdown', async (ctx, isDead, host) => {
|
|
142
|
+
const R = await loadItems(ctx);
|
|
143
|
+
if (isDead()) return null;
|
|
144
|
+
if (!R.ok) return errLine('The work items could not be read: ' + R.error + '.');
|
|
145
|
+
const draw = async (force) => {
|
|
146
|
+
const RR = force ? await loadItems(ctx, true) : R;
|
|
147
|
+
if (isDead()) return;
|
|
148
|
+
host.replaceChildren(...body(RR));
|
|
149
|
+
};
|
|
150
|
+
const changed = () => { dropItems(ctx); draw(true); };
|
|
151
|
+
function body(RR) {
|
|
152
|
+
const work = mine(ctx, RR.items); const epics = work.filter((w) => w.kind === 'Epic');
|
|
153
|
+
const inEpic = (w) => w.kind !== 'Epic' && w.epic && epics.some((e) => e.uid === w.epic);
|
|
154
|
+
const ordered = [...epics.flatMap((e) => [e, ...work.filter((w) => w.epic === e.uid)]), ...work.filter((w) => w.kind !== 'Epic' && !inEpic(w))];
|
|
155
|
+
const F = { epic: '', busy: false };
|
|
156
|
+
const input = el('input', { type: 'text', placeholder: 'Add a PMOS task to ' + ctx.row.id, 'aria-label': 'Add a PMOS task', 'data-wk': 'add-title' });
|
|
157
|
+
const msg = el('div', { class: 'fold w', role: 'alert', hidden: '' });
|
|
158
|
+
const add = async () => {
|
|
159
|
+
const title = input.value.trim();
|
|
160
|
+
if (!title) { msg.textContent = 'Write the task first.'; msg.hidden = false; input.focus(); return; }
|
|
161
|
+
if (F.busy) return; F.busy = true;
|
|
162
|
+
const r = await ctx.api.post('/api/boards/tasks/add', { board: ctx.board.id, initiative: ctx.row.id, title, epic: F.epic || undefined });
|
|
163
|
+
F.busy = false;
|
|
164
|
+
if (!r || !r.ok) { msg.textContent = 'The task was not added: ' + ((r && r.error) || 'no answer') + '.'; msg.hidden = false; return; }
|
|
165
|
+
ctx.toast('PMOS task added to ' + ctx.row.id + '. Drag it on the Timeline to set its dates.');
|
|
166
|
+
changed();
|
|
167
|
+
};
|
|
168
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); add(); } });
|
|
169
|
+
const its = work.filter((w) => w.kind !== 'Epic');
|
|
170
|
+
return [
|
|
171
|
+
card({ ic: 'plus', title: 'Add a PMOS task', sub: ctx.board.build ? 'Jira items are created in Jira' : 'this board has no Jira', attrs: { 'data-wk': 'add' } },
|
|
172
|
+
el('div', { class: 'form' }, input,
|
|
173
|
+
epics.length ? el('select', { 'aria-label': 'Epic for the task', 'data-wk': 'add-epic', onchange: (e) => { F.epic = e.target.value; } }, el('option', { value: '' }, 'No epic'), ...epics.map((e) => el('option', { value: e.uid }, (e.key ? e.key + ' ' : '') + trunc(e.title, 28)))) : null,
|
|
174
|
+
pbtn('outline', 'Add', 'plus', { 'data-wk': 'add-go', onclick: add })), msg),
|
|
175
|
+
sect({ ic: 'tree', title: 'Epics and items', count: work.length, right: el('span', { class: 'dim mono ini-meta' }, its.filter((w) => w.status === 'Done').length + ' of ' + its.length + ' done') },
|
|
176
|
+
ordered.length ? list(ordered.map((w) => workRow(ctx, w, { all: RR.items, epic: false, sub: inEpic(w), onChanged: changed })), { 'data-ini': 'breakdown' }) : empty('No epics or items yet. Add a PMOS task' + (ctx.board.build ? ', or link Jira ' + (ctx.board.jira || '') + ' epics' : '') + '.', { 'data-ini': 'bd-empty' })),
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
return body(R);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/* ── Docs ── */
|
|
183
|
+
tab('docs', async (ctx, isDead) => {
|
|
184
|
+
const lib = await loadDocs(ctx, new Set([ctx.row.id]));
|
|
185
|
+
if (isDead()) return null;
|
|
186
|
+
if (!lib.ok) return errLine('The documents could not be read: ' + lib.error + '.');
|
|
187
|
+
if (!lib.docs.length) return empty('No documents are linked to ' + ctx.row.id + ' yet.', { 'data-inb': 'docs-empty' });
|
|
188
|
+
const box = el('div');
|
|
189
|
+
renderDocs(box, ctx, lib.docs, [ctx.row.id], new Map([[ctx.row.id, ctx.row.title]]), false);
|
|
190
|
+
return box;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/* ── Jira items ── */
|
|
194
|
+
tab('tickets', async (ctx, isDead) => {
|
|
195
|
+
const R = await loadItems(ctx);
|
|
196
|
+
if (isDead()) return null;
|
|
197
|
+
if (!R.ok) return errLine('The Jira items could not be read: ' + R.error + '.');
|
|
198
|
+
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' });
|
|
200
|
+
const its = tk.filter((w) => w.kind !== 'Epic'); const n = Math.max(1, its.length);
|
|
201
|
+
const c = (s) => its.filter((w) => w.status === s).length;
|
|
202
|
+
return cols(
|
|
203
|
+
[el('div', { class: 'fold' }, icon('lock', 'sm'), el('b', null, 'Mirrored from Jira, read-only.'), ' Status, dates and owners are Jira\'s.'),
|
|
204
|
+
sect({ ic: 'check', title: 'Jira items', count: tk.length }, list(tk.map((w) => workRow(ctx, w, { all: R.items })), { 'data-ini': 'tickets' }))],
|
|
205
|
+
[card({ ic: 'report', title: 'By status' }, hbs(['To do', 'In progress', 'In review', 'Done'].map((s) => hbr(s, c(s), c(s) / n, s === 'Done' ? 'ok' : s === 'To do' ? 'pm' : 'acc'))),
|
|
206
|
+
note(its.filter((w) => w.blocked).length + ' blocked.'))]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/* ── Timeline ── */
|
|
210
|
+
tab('timeline', async (ctx, isDead, host) => {
|
|
211
|
+
const R = await loadItems(ctx);
|
|
212
|
+
if (isDead()) return null;
|
|
213
|
+
if (!R.ok) return errLine('The work items could not be read: ' + R.error + '.');
|
|
214
|
+
const r = ctx.row;
|
|
215
|
+
const key = 'i:' + ctx.ws + ':' + r.id;
|
|
216
|
+
const build = (RR) => {
|
|
217
|
+
const work = mine(ctx, RR.items);
|
|
218
|
+
const ini = RR.initiatives.find((i) => i.id === r.id) || r;
|
|
219
|
+
const rows = [iniTlRow(ctx, ini, workTlRows(ctx, work, changed), ctx.board)];
|
|
220
|
+
const { from, to } = tlRange(ctx.today);
|
|
221
|
+
const node = timeline({ key, rows, from, to, today: ctx.today, head: 'Epic > item', defOpen: [r.id, ...work.filter((w) => w.kind === 'Epic').map((e) => e.uid)],
|
|
222
|
+
onMove: (task, days) => rescheduleTask(ctx, task, days, () => { tlStill(key); changed(); }), onLocked: (k) => lockedToast(ctx, k) });
|
|
223
|
+
requestAnimationFrame(() => { if (!isDead()) tlInit(host); });
|
|
224
|
+
return [sect({ ic: 'cal', title: 'Timeline', count: work.length, right: el('span', { class: 'dim mono ini-meta' }, r.start || r.end ? day(r.start) + ' to ' + day(r.end) : 'no dates yet') }, node,
|
|
225
|
+
work.some((w) => !w.start || !w.end) ? note(plural(work.filter((w) => !w.start || !w.end).length, 'item has', 'items have') + ' no dates' + (ctx.board.build ? '; Jira items get theirs from Jira' : '') + '.') : null)];
|
|
226
|
+
};
|
|
227
|
+
async function changed() { dropItems(ctx); const RR = await loadItems(ctx, true); if (!isDead()) host.replaceChildren(...build(RR).filter(Boolean)); }
|
|
228
|
+
return build(R);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/* ── History ── */
|
|
232
|
+
tab('history', async (ctx) => {
|
|
233
|
+
const items = (Array.isArray(ctx.history) ? ctx.history.slice() : []).sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
|
|
234
|
+
if (!items.length) return empty('No moves are recorded for ' + ctx.row.id + ' yet.', { 'data-inb': 'hist-empty' });
|
|
235
|
+
return sect({ ic: 'undo', title: 'History', count: items.length },
|
|
236
|
+
list(items.map((h) => el('li', { class: 'nrow', 'data-inb-hist': h.kind || 'move' }, el('span', { class: 'ki' }, icon(h.kind === 'workflow' ? 'settings' : h.kind === 'ask-accept' ? 'inbox' : 'undo')),
|
|
237
|
+
el('div', { class: 'nb' }, el('span', { class: 'tt' }, histWhat(h)),
|
|
238
|
+
el('div', { class: 'chips' }, chip(icon('user', 'sm'), h.by || 'Unknown'), chip(icon('clock', 'sm'), h.at ? dayLong(String(h.at).slice(0, 10)) : 'No time recorded'), ...(h.warnings || []).map((w) => st('warn', 'alert', w))),
|
|
239
|
+
h.why ? note('Why: ' + h.why) : null), el('span'))), { 'data-inb': 'hist-list' }));
|
|
240
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/* pm/c3-ini-records.js — the stage records of one initiative, drawn as tool cards on its Overview (mockup v5, 34-initiatives.js toolHTML:
|
|
2
|
+
* problem, rice, release (launch checklist), kill, learn, and the sprint burndown). The records are stored by the stage-records routes,
|
|
3
|
+
* which another change builds (route contract: /mnt/project-files/pmos/stage-records/route-contract.md); this file only reads and writes
|
|
4
|
+
* them through ctx.api:
|
|
5
|
+
* GET /api/stage-tools/records?ws&id { records: { problem, rice, launch, kill, learnings } }, each { data, at, by } or null
|
|
6
|
+
* POST /api/stage-tools/records { ws, id, tool, data } -> { record }
|
|
7
|
+
* GET /api/stage-tools/rice?ws&ids=A,B { rows: [{ id, score, rank }] } ranks within the board
|
|
8
|
+
* GET /api/stage-tools/burndown?ws&board&ids=A { available, sprint, committed, done, left, daysLeft, needPerDay, series } | { available: false, reason,
|
|
9
|
+
* unpointed? } ("Jira not enabled on this board" when the board's buildDeliver is false)
|
|
10
|
+
* Shapes as built in PR #607 (the contract's "As built" section): a save never answers 409 (no state folder writes to the import overlay).
|
|
11
|
+
* In demo mode c3-demo-boards.js answers them. When a route is not on this server yet (404), each card shows a quiet empty state and no
|
|
12
|
+
* editor: nothing is invented.
|
|
13
|
+
*
|
|
14
|
+
* RECORD_TOOLS[kind][cat] -> tool ids · RECORD_META { id: [label, icon] } · recordToolsFor(kind, cat)
|
|
15
|
+
* recordCards(ctx, ids, { boardIds }) -> Promise<[section]> one card per id, each `[data-ini-tool-card=<id>]`; boardIds ranks RICE
|
|
16
|
+
*/
|
|
17
|
+
import { el } from './dom.js';
|
|
18
|
+
import { icon, pill, lk, pbtn, chip, note, lbl, card, chgl, kv, hbs, hbr, plural, day, ago } from './c3-ini-ui.js';
|
|
19
|
+
|
|
20
|
+
export const RECORD_META = {
|
|
21
|
+
problem: ['Problem statement', 'target'], rice: ['RICE prioritisation', 'scale'], launch: ['Launch checklist', 'out'],
|
|
22
|
+
burndown: ['Sprint burndown', 'up'], kill: ['Kill conditions', 'x'], learnings: ['Learnings and retro', 'notes'],
|
|
23
|
+
};
|
|
24
|
+
export const RECORD_TOOLS = {
|
|
25
|
+
product: { Discover: ['problem'], Plan: ['rice'], Do: ['launch', 'burndown'], Learn: ['kill', 'learnings'] },
|
|
26
|
+
project: { Discover: [], Plan: [], Do: ['launch'], Learn: ['learnings'] },
|
|
27
|
+
simple: { Discover: [], Plan: [], Do: [], Learn: [] },
|
|
28
|
+
};
|
|
29
|
+
/** The record tools of a category; burndown only on a board with Build and deliver on (it reads the Jira sprint). */
|
|
30
|
+
export const recordToolsFor = (kind, cat, build) => ((RECORD_TOOLS[kind] || RECORD_TOOLS.product)[cat] || []).filter((k) => k !== 'burndown' || build);
|
|
31
|
+
const IMPACT = [0.25, 0.5, 1, 2, 3];
|
|
32
|
+
const KILL = [['watching', 'Watching', 'warn'], ['tripped', 'Tripped', 'bad'], ['cleared', 'Cleared', 'ok']];
|
|
33
|
+
const nid = () => 'r' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36);
|
|
34
|
+
const scoreOf = (r) => Math.round((Number(r.reach) || 0) * (Number(r.impact) || 0) * ((Number(r.confidence) || 0) / 100) / Math.max(0.25, Number(r.effort) || 0));
|
|
35
|
+
const missing = (r) => !r || r.status === 404;
|
|
36
|
+
const quiet = (text) => el('div', { class: 'dim ini-note', 'data-rec': 'quiet' }, text);
|
|
37
|
+
|
|
38
|
+
/** Read the records once per Overview draw. { ok, missing, records, error }. */
|
|
39
|
+
async function loadRecords(ctx) {
|
|
40
|
+
const r = await ctx.api.get('/api/stage-tools/records', { id: ctx.row.id });
|
|
41
|
+
if (missing(r)) return { ok: false, missing: true, records: {}, error: '' };
|
|
42
|
+
if (!r.ok) return { ok: false, missing: false, records: {}, error: r.error || 'no answer' };
|
|
43
|
+
return { ok: true, missing: false, records: r.records || {}, error: '' };
|
|
44
|
+
}
|
|
45
|
+
async function save(ctx, tool, data) {
|
|
46
|
+
const r = await ctx.api.post('/api/stage-tools/records', { id: ctx.row.id, tool, data });
|
|
47
|
+
if (missing(r)) { ctx.toast({ text: 'Stage records are not on this workspace server yet, so this was not saved.', tone: 'error' }); return null; }
|
|
48
|
+
if (!r.ok) { ctx.toast({ text: 'Not saved: ' + (r.error || 'no answer') + '.', tone: 'error' }); return null; }
|
|
49
|
+
return r.record || { data, at: new Date().toISOString(), by: 'You' };
|
|
50
|
+
}
|
|
51
|
+
const stamp = (rec, today) => (rec && rec.at ? note('Saved ' + ago(String(rec.at).slice(0, 10), today) + (rec.by ? ' by ' + rec.by : '') + '.') : null);
|
|
52
|
+
|
|
53
|
+
export async function recordCards(ctx, ids, opts) {
|
|
54
|
+
opts = opts || {};
|
|
55
|
+
if (!ids.length) return [];
|
|
56
|
+
const R = await loadRecords(ctx);
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const id of ids) {
|
|
59
|
+
const meta = RECORD_META[id]; if (!meta) continue;
|
|
60
|
+
const host = el('div', { 'data-rec': id });
|
|
61
|
+
out.push(card({ ic: meta[1], title: meta[0], attrs: { 'data-ini-tool-card': id, id: 'tool-' + id } }, host));
|
|
62
|
+
if (id === 'burndown') { burndown(ctx, host); continue; }
|
|
63
|
+
if (R.missing) { host.replaceChildren(quiet('Nothing recorded yet.')); continue; }
|
|
64
|
+
if (!R.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The records could not be read: ' + R.error + '.')); continue; }
|
|
65
|
+
const rec = R.records[id] || null;
|
|
66
|
+
({ problem, rice, launch, kill, learnings })[id](ctx, host, rec, opts);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ── problem: one text ── */
|
|
72
|
+
function problem(ctx, host, rec) {
|
|
73
|
+
const S = { text: (rec && rec.data && rec.data.text) || '', rec };
|
|
74
|
+
const draw = () => {
|
|
75
|
+
const ta = el('textarea', { rows: '3', 'aria-label': 'Problem statement', 'data-rec-field': 'problem', placeholder: 'Who has the problem, what it costs them, and how we know', maxlength: '4000' });
|
|
76
|
+
ta.value = S.text;
|
|
77
|
+
const go = pbtn('outline', 'Save', null, { 'data-rec-save': 'problem', onclick: async () => { const r = await save(ctx, 'problem', { text: ta.value.trim() }); if (r) { S.text = ta.value.trim(); S.rec = r; ctx.toast('Problem statement saved.'); draw(); } } });
|
|
78
|
+
host.replaceChildren(el('div', { class: 'form ini-text' }, ta), el('div', { class: 'chips2' }, go, note('Write it before ideas. The evidence should back each claim.')), stamp(S.rec, ctx.today));
|
|
79
|
+
};
|
|
80
|
+
draw();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ── rice: four inputs, the score, the rank on the board ── */
|
|
84
|
+
function rice(ctx, host, rec, opts) {
|
|
85
|
+
const d = (rec && rec.data) || {};
|
|
86
|
+
const S = { reach: d.reach ?? '', impact: d.impact ?? 1, confidence: d.confidence ?? '', effort: d.effort ?? '', rank: '', rec };
|
|
87
|
+
const score = el('b', { 'data-rec': 'rice-score' });
|
|
88
|
+
const rank = el('span', { class: 'dim ini-note', 'data-rec': 'rice-rank' });
|
|
89
|
+
const upd = () => { score.textContent = S.reach !== '' && S.confidence !== '' && S.effort !== '' ? String(scoreOf(S)) : 'not scored'; };
|
|
90
|
+
const num = (k, label, help) => {
|
|
91
|
+
const i = el('input', { type: 'text', inputmode: 'decimal', class: 'ini-num', value: String(S[k]), 'aria-label': label + ', ' + help, title: help, 'data-rec-rice': k, oninput: (e) => { S[k] = e.target.value.trim(); upd(); } });
|
|
92
|
+
return el('label', { class: 'chips2' }, lbl(label), i);
|
|
93
|
+
};
|
|
94
|
+
async function readRank() {
|
|
95
|
+
const r = await ctx.api.get('/api/stage-tools/rice', { ids: (opts.boardIds || []).join(',') });
|
|
96
|
+
if (!r || !r.ok) { rank.textContent = ''; return; }
|
|
97
|
+
const rows = r.rows || []; const me = rows.find((x) => x.id === ctx.row.id);
|
|
98
|
+
rank.textContent = me ? 'Ranks ' + me.rank + ' of ' + rows.length + ' scored initiatives on ' + ctx.board.name : rows.length ? 'Not ranked yet: ' + plural(rows.length, 'initiative') + ' on ' + ctx.board.name + ' are scored.' : '';
|
|
99
|
+
}
|
|
100
|
+
async function go() {
|
|
101
|
+
const data = { reach: Number(S.reach), impact: Number(S.impact), confidence: Number(S.confidence), effort: Number(S.effort) };
|
|
102
|
+
if (![data.reach, data.confidence, data.effort].every((n) => Number.isFinite(n)) || data.effort <= 0 || data.confidence < 0 || data.confidence > 100 || data.reach < 0) {
|
|
103
|
+
ctx.toast({ text: 'Reach is a number, confidence 0 to 100, and effort more than 0.', tone: 'error' }); return;
|
|
104
|
+
}
|
|
105
|
+
const r = await save(ctx, 'rice', data);
|
|
106
|
+
if (r) { S.rec = r; ctx.toast('RICE saved: score ' + scoreOf(data) + '.'); readRank(); host.querySelector('[data-rec="stamp"]')?.replaceWith(stampNode()); }
|
|
107
|
+
}
|
|
108
|
+
const stampNode = () => { const n = stamp(S.rec, ctx.today) || el('span'); n.setAttribute('data-rec', 'stamp'); return n; };
|
|
109
|
+
host.replaceChildren(
|
|
110
|
+
el('div', { class: 'chips2' }, num('reach', 'Reach', 'accounts a quarter'),
|
|
111
|
+
el('label', { class: 'chips2' }, lbl('Impact'), el('select', { 'aria-label': 'Impact, 0.25 to 3', 'data-rec-rice': 'impact', onchange: (e) => { S.impact = Number(e.target.value); upd(); } }, ...IMPACT.map((v) => el('option', { value: String(v), selected: Number(S.impact) === v }, String(v))))),
|
|
112
|
+
num('confidence', 'Confidence', 'percent'), num('effort', 'Effort', 'person-months')),
|
|
113
|
+
el('div', { class: 'chips2' }, el('span', null, 'Score ', score), rank, pbtn('outline', 'Save', null, { 'data-rec-save': 'rice', onclick: go })),
|
|
114
|
+
note('Score = reach × impact × confidence ÷ effort. The inputs stay visible so the ranking can be argued with.'), stampNode());
|
|
115
|
+
upd(); readRank();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ── the three lists: launch checklist, kill conditions, learnings ── */
|
|
119
|
+
function listTool(ctx, host, rec, o) {
|
|
120
|
+
const S = { items: Array.isArray(rec && rec.data && rec.data.items) ? rec.data.items.map((x) => ({ ...x })) : [], rec, busy: false };
|
|
121
|
+
async function commit(next, msg) {
|
|
122
|
+
if (S.busy) return;
|
|
123
|
+
S.busy = true;
|
|
124
|
+
const r = await save(ctx, o.tool, { items: next });
|
|
125
|
+
S.busy = false;
|
|
126
|
+
if (r) { S.items = next; S.rec = r; if (msg) ctx.toast(msg); }
|
|
127
|
+
draw();
|
|
128
|
+
}
|
|
129
|
+
function draw(focus) {
|
|
130
|
+
const input = el('input', { type: 'text', placeholder: o.placeholder, 'aria-label': o.addLabel, 'data-rec-add': o.tool, maxlength: '300' });
|
|
131
|
+
const add = () => { const t = input.value.trim(); if (!t) { input.focus(); return; } if (S.items.length >= o.max) { ctx.toast({ text: 'At most ' + o.max + ' items.', tone: 'error' }); return; } commit([...S.items, o.make(t)], null).then(() => { const f = host.querySelector('[data-rec-add]'); if (f) f.focus(); }); };
|
|
132
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); add(); } });
|
|
133
|
+
host.replaceChildren(
|
|
134
|
+
S.items.length ? o.list(S.items, (next, msg) => commit(next, msg)) : note(o.empty),
|
|
135
|
+
el('div', { class: 'form' }, input, pbtn('outline', 'Add', 'plus', { 'data-rec-addgo': o.tool, onclick: add })),
|
|
136
|
+
o.foot ? o.foot(S.items) : null, stamp(S.rec, ctx.today));
|
|
137
|
+
if (focus) { const f = host.querySelector(focus); if (f) f.focus(); }
|
|
138
|
+
}
|
|
139
|
+
draw();
|
|
140
|
+
}
|
|
141
|
+
function launch(ctx, host, rec) {
|
|
142
|
+
listTool(ctx, host, rec, { tool: 'launch', max: 40, placeholder: 'Add a launch step', addLabel: 'Add a launch step', empty: 'No launch checklist yet. List what has to be true on launch day.',
|
|
143
|
+
make: (t) => ({ id: nid(), text: t, done: false }),
|
|
144
|
+
list: (items, commit) => el('div', { class: 'ini-checks', 'data-rec': 'launch-list' }, ...items.map((c, n) => el('label', { class: 'ini-chk' + (c.done ? ' done' : '') },
|
|
145
|
+
el('input', { type: 'checkbox', checked: !!c.done, 'data-rec-check': String(n), onchange: (e) => commit(items.map((x, k) => (k === n ? { ...x, done: e.target.checked } : x)), null) }), el('span', null, c.text)))),
|
|
146
|
+
foot: (items) => (items.length ? note(items.filter((c) => c.done).length + ' of ' + items.length + ' done') : null) });
|
|
147
|
+
}
|
|
148
|
+
function kill(ctx, host, rec) {
|
|
149
|
+
listTool(ctx, host, rec, { tool: 'kill', max: 20, placeholder: 'A result that would make you stop', addLabel: 'Add a kill condition', empty: 'No kill conditions. Say what result would make you stop.',
|
|
150
|
+
make: (t) => ({ id: nid(), text: t, status: 'watching' }),
|
|
151
|
+
list: (items, commit) => chgl(items.map((k, n) => el('li', { class: k.status === 'tripped' ? 'bad' : k.status === 'watching' ? 'warn' : '', 'data-rec-kill': String(n) }, icon('x', 'sm'), el('span', null, k.text),
|
|
152
|
+
el('span', { class: 'seg ini-seg', role: 'group', 'aria-label': 'Status of ' + k.text }, ...KILL.map(([v, l]) => pill({ 'aria-pressed': String(k.status === v), 'data-rec-killst': v, onclick: () => { if (k.status !== v) commit(items.map((x, j) => (j === n ? { ...x, status: v } : x)), v === 'tripped' ? 'Kill condition tripped. Decide whether to stop.' : null); } }, l)))))) });
|
|
153
|
+
}
|
|
154
|
+
function learnings(ctx, host, rec) {
|
|
155
|
+
listTool(ctx, host, rec, { tool: 'learnings', max: 60, placeholder: 'What we learned, or would do differently', addLabel: 'Add a learning', empty: 'No learnings yet. Add them as they come, not only at the end.',
|
|
156
|
+
make: (t) => ({ id: nid(), text: t, date: ctx.today }),
|
|
157
|
+
list: (items) => chgl(items.slice().reverse().map((x) => el('li', { 'data-rec-learning': x.id }, icon('notes', 'sm'), el('span', null, x.text), el('span', { class: 'tag' }, day(x.date) || 'no date')))) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ── burndown: read-only, mirrored from the Jira sprint ── */
|
|
161
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
162
|
+
const svg = (tag, attrs) => { const n = document.createElementNS(SVGNS, tag); for (const [k, v] of Object.entries(attrs || {})) n.setAttribute(k, String(v)); return n; };
|
|
163
|
+
function burnSvg(b, W, H) {
|
|
164
|
+
const series = (b.series || []).filter((p) => p && p.date);
|
|
165
|
+
const max = Math.max(1, Number(b.committed) || 0, ...series.map((p) => Number(p.remaining) || 0));
|
|
166
|
+
const n = Math.max(1, series.length - 1);
|
|
167
|
+
const x = (k) => 30 + (k / n) * (W - 40); const y = (v) => 8 + (1 - v / max) * (H - 26);
|
|
168
|
+
const s = svg('svg', { class: 'ini-burn', viewBox: '0 0 ' + W + ' ' + H, role: 'img', 'aria-label': 'Burndown: ' + (b.left ?? '') + ' of ' + (b.committed ?? '') + ' points left' });
|
|
169
|
+
s.append(svg('line', { class: 'ax', x1: 30, y1: y(0), x2: W - 10, y2: y(0) }), svg('line', { class: 'ax', x1: 30, y1: 8, x2: 30, y2: y(0) }));
|
|
170
|
+
if (series.length) {
|
|
171
|
+
s.append(svg('path', { class: 'ideal', d: series.map((p, k) => (k ? 'L' : 'M') + x(k).toFixed(1) + ' ' + y(Number(p.ideal) || 0).toFixed(1)).join(' ') }));
|
|
172
|
+
const act = series.filter((p) => p.remaining != null);
|
|
173
|
+
if (act.length) {
|
|
174
|
+
s.append(svg('path', { class: 'act', d: act.map((p, k) => (k ? 'L' : 'M') + x(k).toFixed(1) + ' ' + y(Number(p.remaining)).toFixed(1)).join(' ') }));
|
|
175
|
+
const k = act.length - 1; s.append(svg('circle', { class: 'dot', cx: x(k), cy: y(Number(act[k].remaining)), r: 3.5 }));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const t = (tx, ty, text, anchor) => { const e = svg('text', { x: tx, y: ty, 'text-anchor': anchor || 'start' }); e.textContent = text; return e; };
|
|
179
|
+
s.append(t(26, y(max) + 4, String(max), 'end'), t(26, y(0) + 3, '0', 'end'));
|
|
180
|
+
if (series.length) s.append(t(30, H - 4, day(series[0].date)), t(W - 10, H - 4, day(series[series.length - 1].date), 'end'));
|
|
181
|
+
return s;
|
|
182
|
+
}
|
|
183
|
+
async function burndown(ctx, host) {
|
|
184
|
+
host.replaceChildren(note('Reading the sprint'));
|
|
185
|
+
const r = await ctx.api.get('/api/stage-tools/burndown', { board: ctx.board.id, ids: ctx.row.id });
|
|
186
|
+
if (missing(r)) { host.replaceChildren(quiet('No sprint burndown yet.')); return; }
|
|
187
|
+
if (!r.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The burndown could not be read: ' + (r.error || 'no answer') + '.')); return; }
|
|
188
|
+
if (!r.available) { host.replaceChildren(quiet(r.reason || 'The Jira snapshot has no sprint dates or points yet.')); return; }
|
|
189
|
+
const sp = r.sprint || {};
|
|
190
|
+
host.replaceChildren(
|
|
191
|
+
el('div', { class: 'chips2' }, chip(icon('lock', 'sm'), 'Mirrored from Jira, read-only'), chip(icon('clock', 'sm'), (sp.name || 'Sprint') + ' · ' + day(sp.start) + ' to ' + day(sp.end))),
|
|
192
|
+
burnSvg(r, 360, 140),
|
|
193
|
+
kv([['Committed', String(r.committed ?? 0) + ' points'], ['Done', String(r.done ?? 0)], ['Left · ' + plural(Number(r.daysLeft) || 0, 'day'), String(r.left ?? 0)]]),
|
|
194
|
+
hbs([hbr('Done', (r.done ?? 0) + ' of ' + (r.committed ?? 0), (Number(r.done) || 0) / Math.max(1, Number(r.committed) || 0), 'ok')]),
|
|
195
|
+
note((r.left ?? 0) + ' points left with ' + plural(Number(r.daysLeft) || 0, 'day') + ' to go: needs ' + (Number(r.needPerDay) || 0).toFixed(1) + ' a day.' + (r.unpointed ? ' ' + plural(r.unpointed, 'ticket has', 'tickets have') + ' no points.' : '')),
|
|
196
|
+
lk({ onclick: () => ctx.openBoard(ctx.board.id, 'sprints') }, icon('next', 'sm'), 'Open ' + ctx.board.name + ' Sprints'));
|
|
197
|
+
}
|