create-pmos 0.158.0 → 0.160.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/c3-initiatives-test.js +8 -2
- package/workspace/guide/ask-the-agent.md +1 -1
- package/workspace/guide/briefing.md +2 -2
- package/workspace/guide/calendar-popup.md +1 -1
- package/workspace/guide/console-calendar.md +2 -2
- package/workspace/guide/index.md +1 -1
- package/workspace/guide/initiative-boards.md +4 -4
- package/workspace/guide/layout.md +5 -3
- package/workspace/guide/notes.md +1 -1
- package/workspace/guide/settings.md +2 -2
- package/workspace/lib/initiative-rows.js +33 -3
- package/workspace/lib/routes-boards.js +8 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/c3.html +11 -2
- package/workspace/public/pm/c3-demo-boards.js +6 -0
- package/workspace/public/pm/c3-dock.js +7 -7
- package/workspace/public/pm/c3-ib-docs.js +9 -9
- package/workspace/public/pm/c3-ib-initiatives.js +7 -7
- package/workspace/public/pm/c3-ib-items.js +4 -4
- package/workspace/public/pm/c3-ib-releases.js +4 -4
- package/workspace/public/pm/c3-ib-sprints.js +5 -5
- package/workspace/public/pm/c3-ib-triage.js +6 -6
- package/workspace/public/pm/c3-ii-tabs.js +6 -6
- package/workspace/public/pm/c3-ini-records.js +11 -11
- package/workspace/public/pm/c3-ini-timeline.js +3 -3
- package/workspace/public/pm/c3-ini-ui.js +7 -0
- package/workspace/public/pm/c3-ini-work.js +2 -2
- package/workspace/public/pm/c3-initiatives.js +42 -23
- package/workspace/public/pm/c3-plan.js +1 -1
- package/workspace/public/pm/c3-settings.js +2 -2
- package/workspace/public/pm/c3-shell.js +3 -3
- package/workspace/public/pm/c3-tickets.js +1 -1
- package/workspace/public/pm/console3.css +3 -0
- package/workspace/public/pm/pm-work-view.js +1 -1
- package/workspace/public/pm/sessions-panel.js +2 -2
- package/workspace/public/pm/shell-recording.js +1 -1
- package/workspace/public/pm/welcome.js +2 -2
- package/workspace/server.js +4 -2
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* its id, one with no date says "No date yet". */
|
|
9
9
|
import { el } from './dom.js';
|
|
10
10
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
11
|
-
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, jiraLabel, plural, day, daysBetween } from './c3-ini-ui.js';
|
|
11
|
+
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, jiraLabel, plural, day, daysBetween, put } from './c3-ini-ui.js';
|
|
12
12
|
import { loadItems } from './c3-ini-work.js';
|
|
13
13
|
|
|
14
14
|
const whenOf = (r) => String(r.release_at || r.deploy_at || r.date || '').slice(0, 10);
|
|
@@ -17,10 +17,10 @@ const relSt = (s) => (/at risk/i.test(s) ? st('warn', 'alert', 'At risk') : /rea
|
|
|
17
17
|
registerBoardTab('releases', async (host, ctx) => {
|
|
18
18
|
let dead = false;
|
|
19
19
|
const b = ctx.board;
|
|
20
|
-
host
|
|
20
|
+
put(host, note('Reading the releases'));
|
|
21
21
|
const [items, rel] = await Promise.all([loadItems(ctx), ctx.api.get('/api/releases', { board: b.id })]);
|
|
22
22
|
if (dead) return () => {};
|
|
23
|
-
const fail = (t) => { host
|
|
23
|
+
const fail = (t) => { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), t)); return () => { dead = true; }; };
|
|
24
24
|
if (!items.ok) return fail('The initiatives of this board could not be read: ' + items.error + '.');
|
|
25
25
|
if (!rel || !rel.ok) return fail('The releases could not be read: ' + ((rel && rel.error) || 'no answer') + '.');
|
|
26
26
|
const byId = new Map(items.initiatives.map((i) => [i.id, i]));
|
|
@@ -42,7 +42,7 @@ registerBoardTab('releases', async (host, ctx) => {
|
|
|
42
42
|
: [el('span', { class: 'dim' }, 'No initiatives in scope')]))],
|
|
43
43
|
attrs: { 'data-inb-release': r.id, 'data-inb-when': isPast ? 'past' : r === up[0] ? 'next' : 'later' } });
|
|
44
44
|
};
|
|
45
|
-
host
|
|
45
|
+
put(host, cols(
|
|
46
46
|
[sect({ ic: 'out', title: 'Unreleased', count: up.length, attrs: { 'data-inb': 'release-next' } }, up.length ? list(up.map((r) => row(r, false))) : empty('No release planned for ' + b.name + '.', { 'data-inb': 'release-none-next' })),
|
|
47
47
|
past.length ? sect({ ic: 'check', title: 'Released', count: past.length, attrs: { 'data-inb': 'release-past' } }, list(past.map((r) => row(r, true)))) : null],
|
|
48
48
|
[card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + jiraLabel(b) + ' fix versions, because this board builds and delivers. Release dates are references: their home is the Calendar.'),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the stage-records change; when that route is not on this server yet, the card says so quietly). source 'none' says there is no tracker. */
|
|
8
8
|
import { el } from './dom.js';
|
|
9
9
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
10
|
-
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, jiraLabel, plural, day } from './c3-ini-ui.js';
|
|
10
|
+
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, jiraLabel, plural, day, put } from './c3-ini-ui.js';
|
|
11
11
|
import { loadItems } from './c3-ini-work.js';
|
|
12
12
|
import { recordCards } from './c3-ini-records.js';
|
|
13
13
|
|
|
@@ -18,11 +18,11 @@ const countsOf = (sp) => { const c = sp.counts || {}; const f = (k) => (c[k] !=
|
|
|
18
18
|
registerBoardTab('sprints', async (host, ctx) => {
|
|
19
19
|
let dead = false;
|
|
20
20
|
const b = ctx.board;
|
|
21
|
-
host
|
|
21
|
+
put(host, note('Reading the sprints'));
|
|
22
22
|
const [r, items] = await Promise.all([ctx.api.get('/api/boards/sprints', { board: b.id }), loadItems(ctx)]);
|
|
23
23
|
if (dead) return () => {};
|
|
24
|
-
if (!r || !r.ok) { host
|
|
25
|
-
if (r.source === 'none') { host
|
|
24
|
+
if (!r || !r.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The sprints could not be read: ' + ((r && r.error) || 'no answer') + '.')); return () => { dead = true; }; }
|
|
25
|
+
if (r.source === 'none') { put(host, empty('Waiting for the first sync from Jira ' + jiraLabel(b) + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
|
|
26
26
|
const sprints = r.sprints || [];
|
|
27
27
|
const active = sprints.find((s) => s.state === 'active');
|
|
28
28
|
const others = sprints.filter((s) => s !== active);
|
|
@@ -50,6 +50,6 @@ registerBoardTab('sprints', async (host, ctx) => {
|
|
|
50
50
|
}
|
|
51
51
|
right.push(card({ ic: 'out', title: 'In Jira' }, note('Sprints, their dates and their items are Jira\'s. PMOS mirrors them after each sync.'), el('div', { class: 'chips2' }, pbtn('outline', 'Open the Board', 'cols', { onclick: () => ctx.openBoard(b.id, 'board') }))));
|
|
52
52
|
if (dead) return () => {};
|
|
53
|
-
host
|
|
53
|
+
put(host, cols(left, right));
|
|
54
54
|
return () => { dead = true; };
|
|
55
55
|
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* anchor_suggestion, prior_rejections: [{ id, title, why }] }, initiative? (accepted), why? (declined) }. Nothing is invented. */
|
|
14
14
|
import { el } from './dom.js';
|
|
15
15
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
16
|
-
import { icon, pbtn, lk, chip, tag, st, note, noteA, lbl, sect, card, list, empty, nrow, cols, chgl, ago } from './c3-ini-ui.js';
|
|
16
|
+
import { icon, pbtn, lk, chip, tag, st, note, noteA, lbl, sect, card, list, empty, nrow, cols, chgl, ago, put } from './c3-ini-ui.js';
|
|
17
17
|
import { TEMPLATES } from './c3-ini-work.js';
|
|
18
18
|
|
|
19
19
|
const LANES = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'];
|
|
@@ -38,7 +38,7 @@ registerBoardTab('triage', async (host, ctx) => {
|
|
|
38
38
|
chgl([el('li', null, icon('check', 'sm'), el('span', null, 'Accept: starts an initiative (' + def.name + ', ' + first.name + ')'), el('span')),
|
|
39
39
|
el('li', null, icon('reply', 'sm'), el('span', null, 'Ask for more: the asker is prompted for detail'), el('span')),
|
|
40
40
|
el('li', null, icon('x', 'sm'), el('span', null, 'Decline: the asker is told why'), el('span'))]));
|
|
41
|
-
host
|
|
41
|
+
put(host, cols(left, right));
|
|
42
42
|
const setErr = (t) => { msg.textContent = t || ''; msg.hidden = !t; };
|
|
43
43
|
|
|
44
44
|
function draftNodes(a) {
|
|
@@ -68,7 +68,7 @@ registerBoardTab('triage', async (host, ctx) => {
|
|
|
68
68
|
attrs: { 'data-ibt-ask': a.id, 'data-state': state } });
|
|
69
69
|
}
|
|
70
70
|
function draw() {
|
|
71
|
-
left
|
|
71
|
+
put(left,
|
|
72
72
|
card({ ic: 'plus', title: 'Add an ask' }, el('div', { class: 'form', 'data-ibt': 'add-form' }, input, addBtn), msg),
|
|
73
73
|
sect({ ic: 'inbox', title: 'New asks', count: S.open.length }, S.open.length ? list(S.open.map((a) => row(a, 'open')), { 'data-ibt': 'asks', 'aria-label': 'Open asks' }) : empty('Nothing new to triage on this board.', { 'data-ibt': 'empty' })),
|
|
74
74
|
S.other.length ? sect({ ic: 'check', title: 'Decided', count: S.other.length, attrs: { 'data-ibt': 'closed' } }, list(S.other.map((x) => row(x, x.state)))) : null);
|
|
@@ -130,7 +130,7 @@ registerBoardTab('triage', async (host, ctx) => {
|
|
|
130
130
|
ctx.toast({ text: 'Accepted as ' + id + ' in ' + (r.stage || first.name) + ', with the ' + def.name + ' workflow.', actionLabel: 'Open ' + id, onAction: () => ctx.openInitiative(id), timeout: 9000 });
|
|
131
131
|
ctx.refresh();
|
|
132
132
|
}
|
|
133
|
-
body
|
|
133
|
+
put(body, note('"' + a.line + '"'), fieldOf('TITLE', title), fieldOf('LANE', lane), fieldOf('ANCHOR', anchor), why, fail,
|
|
134
134
|
el('div', { class: 'chips2' }, go, pbtn('ghost', 'Cancel', null, { onclick: () => sheet.close() })));
|
|
135
135
|
sync();
|
|
136
136
|
title.focus();
|
|
@@ -149,7 +149,7 @@ registerBoardTab('triage', async (host, ctx) => {
|
|
|
149
149
|
ctx.toast('The asker is prompted for more detail. The ask stays in triage.');
|
|
150
150
|
if (!S.dead) { a.needMore = true; a.moreNote = F.note.trim(); draw(); }
|
|
151
151
|
} });
|
|
152
|
-
body
|
|
152
|
+
put(body, note('"' + a.line + '"' + (a.source ? ' · ' + a.source : '')), fieldOf('QUESTION', q), fail, el('div', { class: 'chips2' }, go, pbtn('ghost', 'Cancel', null, { onclick: () => sheet.close() })));
|
|
153
153
|
q.focus();
|
|
154
154
|
}
|
|
155
155
|
function openDecline(a) {
|
|
@@ -167,7 +167,7 @@ registerBoardTab('triage', async (host, ctx) => {
|
|
|
167
167
|
ctx.toast('Ask declined. The asker is told why.');
|
|
168
168
|
ctx.refresh();
|
|
169
169
|
} });
|
|
170
|
-
body
|
|
170
|
+
put(body, note('"' + a.line + '"'), el('label', { class: 'ini-field' }, lbl('REASON'), el('div', { class: 'form ini-text' }, why)), fail,
|
|
171
171
|
el('div', { class: 'chips2' }, go, pbtn('ghost', 'Cancel', null, { onclick: () => sheet.close() })));
|
|
172
172
|
why.focus();
|
|
173
173
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Every tab has an honest empty state and an error line when its read fails. */
|
|
15
15
|
import { el } from './dom.js';
|
|
16
16
|
import { registerInitiativeTab } from './c3-initiatives.js';
|
|
17
|
-
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, jiraLabel, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
|
|
17
|
+
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, jiraLabel, plural, trunc, day, dayLong, ago, PORTFOLIO, put } from './c3-ini-ui.js';
|
|
18
18
|
import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
19
19
|
import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
|
|
20
20
|
import { loadDocs, renderDocs } from './c3-ib-docs.js';
|
|
@@ -27,11 +27,11 @@ const toneOf = (s) => (s === 'late' ? 'bad' : s === 'at risk' ? 'warn' : 'ok');
|
|
|
27
27
|
function tab(id, build) {
|
|
28
28
|
registerInitiativeTab(id, async (host, ctx) => {
|
|
29
29
|
let dead = false;
|
|
30
|
-
host
|
|
30
|
+
put(host, note('Reading'));
|
|
31
31
|
try {
|
|
32
32
|
const nodes = await build(ctx, () => dead, host);
|
|
33
|
-
if (!dead && nodes) host
|
|
34
|
-
} catch (e) { if (!dead) host
|
|
33
|
+
if (!dead && nodes) put(host, ...[].concat(nodes).filter(Boolean));
|
|
34
|
+
} catch (e) { if (!dead) put(host, errLine('This view could not be drawn: ' + ((e && e.message) || e) + '.')); }
|
|
35
35
|
return () => { dead = true; };
|
|
36
36
|
});
|
|
37
37
|
}
|
|
@@ -149,7 +149,7 @@ tab('breakdown', async (ctx, isDead, host) => {
|
|
|
149
149
|
const draw = async (force) => {
|
|
150
150
|
const RR = force ? await loadItems(ctx, true) : R;
|
|
151
151
|
if (isDead()) return;
|
|
152
|
-
host
|
|
152
|
+
put(host, ...body(RR));
|
|
153
153
|
};
|
|
154
154
|
const changed = () => { dropItems(ctx); draw(true); };
|
|
155
155
|
function body(RR) {
|
|
@@ -228,7 +228,7 @@ tab('timeline', async (ctx, isDead, host) => {
|
|
|
228
228
|
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,
|
|
229
229
|
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)];
|
|
230
230
|
};
|
|
231
|
-
async function changed() { dropItems(ctx); const RR = await loadItems(ctx, true); if (!isDead()) host
|
|
231
|
+
async function changed() { dropItems(ctx); const RR = await loadItems(ctx, true); if (!isDead()) put(host, ...build(RR).filter(Boolean)); }
|
|
232
232
|
return build(R);
|
|
233
233
|
});
|
|
234
234
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* recordCards(ctx, ids, { boardIds }) -> Promise<[section]> one card per id, each `[data-ini-tool-card=<id>]`; boardIds ranks RICE
|
|
16
16
|
*/
|
|
17
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';
|
|
18
|
+
import { icon, pill, lk, pbtn, chip, note, lbl, card, chgl, kv, hbs, hbr, plural, day, ago, put } from './c3-ini-ui.js';
|
|
19
19
|
|
|
20
20
|
export const RECORD_META = {
|
|
21
21
|
problem: ['Problem statement', 'target'], rice: ['RICE prioritisation', 'scale'], launch: ['Launch checklist', 'out'],
|
|
@@ -60,8 +60,8 @@ export async function recordCards(ctx, ids, opts) {
|
|
|
60
60
|
const host = el('div', { 'data-rec': id });
|
|
61
61
|
out.push(card({ ic: meta[1], title: meta[0], attrs: { 'data-ini-tool-card': id, id: 'tool-' + id } }, host));
|
|
62
62
|
if (id === 'burndown') { burndown(ctx, host); continue; }
|
|
63
|
-
if (R.missing) { host
|
|
64
|
-
if (!R.ok) { host
|
|
63
|
+
if (R.missing) { put(host, quiet('Nothing recorded yet.')); continue; }
|
|
64
|
+
if (!R.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The records could not be read: ' + R.error + '.')); continue; }
|
|
65
65
|
const rec = R.records[id] || null;
|
|
66
66
|
({ problem, rice, launch, kill, learnings })[id](ctx, host, rec, opts);
|
|
67
67
|
}
|
|
@@ -75,7 +75,7 @@ function problem(ctx, host, rec) {
|
|
|
75
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
76
|
ta.value = S.text;
|
|
77
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
|
|
78
|
+
put(host, 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
79
|
};
|
|
80
80
|
draw();
|
|
81
81
|
}
|
|
@@ -106,7 +106,7 @@ function rice(ctx, host, rec, opts) {
|
|
|
106
106
|
if (r) { S.rec = r; ctx.toast('RICE saved: score ' + scoreOf(data) + '.'); readRank(); host.querySelector('[data-rec="stamp"]')?.replaceWith(stampNode()); }
|
|
107
107
|
}
|
|
108
108
|
const stampNode = () => { const n = stamp(S.rec, ctx.today) || el('span'); n.setAttribute('data-rec', 'stamp'); return n; };
|
|
109
|
-
host
|
|
109
|
+
put(host,
|
|
110
110
|
el('div', { class: 'chips2' }, num('reach', 'Reach', 'accounts a quarter'),
|
|
111
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
112
|
num('confidence', 'Confidence', 'percent'), num('effort', 'Effort', 'person-months')),
|
|
@@ -130,7 +130,7 @@ function listTool(ctx, host, rec, o) {
|
|
|
130
130
|
const input = el('input', { type: 'text', placeholder: o.placeholder, 'aria-label': o.addLabel, 'data-rec-add': o.tool, maxlength: '300' });
|
|
131
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
132
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); add(); } });
|
|
133
|
-
host
|
|
133
|
+
put(host,
|
|
134
134
|
S.items.length ? o.list(S.items, (next, msg) => commit(next, msg)) : note(o.empty),
|
|
135
135
|
el('div', { class: 'form' }, input, pbtn('outline', 'Add', 'plus', { 'data-rec-addgo': o.tool, onclick: add })),
|
|
136
136
|
o.foot ? o.foot(S.items) : null, stamp(S.rec, ctx.today));
|
|
@@ -181,13 +181,13 @@ function burnSvg(b, W, H) {
|
|
|
181
181
|
return s;
|
|
182
182
|
}
|
|
183
183
|
async function burndown(ctx, host) {
|
|
184
|
-
host
|
|
184
|
+
put(host, note('Reading the sprint'));
|
|
185
185
|
const r = await ctx.api.get('/api/stage-tools/burndown', { board: ctx.board.id, ids: ctx.row.id });
|
|
186
|
-
if (missing(r)) { host
|
|
187
|
-
if (!r.ok) { host
|
|
188
|
-
if (!r.available) { host
|
|
186
|
+
if (missing(r)) { put(host, quiet('No sprint burndown yet.')); return; }
|
|
187
|
+
if (!r.ok) { put(host, 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) { put(host, quiet(r.reason || 'The Jira snapshot has no sprint dates or points yet.')); return; }
|
|
189
189
|
const sp = r.sprint || {};
|
|
190
|
-
host
|
|
190
|
+
put(host,
|
|
191
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
192
|
burnSvg(r, 360, 140),
|
|
193
193
|
kv([['Committed', String(r.committed ?? 0) + ' points'], ['Done', String(r.done ?? 0)], ['Left · ' + plural(Number(r.daysLeft) || 0, 'day'), String(r.left ?? 0)]]),
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* pans it (mouse). Pointer handlers are on the document (R28-15); every control is a button. Reduced motion turns the motion off.
|
|
20
20
|
*/
|
|
21
21
|
import { el } from './dom.js';
|
|
22
|
-
import { btn, icon, geo, day, parseDay } from './c3-ini-ui.js';
|
|
22
|
+
import { btn, icon, geo, day, parseDay, put } from './c3-ini-ui.js';
|
|
23
23
|
|
|
24
24
|
const DAY = 864e5;
|
|
25
25
|
export const ZOOMS = [['week', 'Week', 22], ['month', 'Month', 6], ['quarter', 'Quarter', 2]];
|
|
@@ -125,7 +125,7 @@ export function timeline({ key, rows, from, to, today, markers = [], defOpen, he
|
|
|
125
125
|
node.classList.add('zooming');
|
|
126
126
|
const finish = () => {
|
|
127
127
|
geo(node, { '--in-pd': p1 }); sc.scrollLeft = center * p1 - view / 2; st.scroll = sc.scrollLeft;
|
|
128
|
-
ax
|
|
128
|
+
put(ax, ...axis(from, days, z)); put(gr, ...grid(from, days, z));
|
|
129
129
|
node.classList.remove('zooming');
|
|
130
130
|
};
|
|
131
131
|
if (reduced()) { finish(); return; }
|
|
@@ -174,7 +174,7 @@ function tipShow(bar, ev, nd, extra) {
|
|
|
174
174
|
const s = new Date(+parseDay(bar.dataset.s) + (nd || 0) * DAY); const e = new Date(+parseDay(bar.dataset.e) + (nd || 0) * DAY);
|
|
175
175
|
const iso = (d) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
176
176
|
const len = Math.max(1, Math.round((e - s) / DAY));
|
|
177
|
-
I.tip
|
|
177
|
+
put(I.tip, el('b', null, t.t || r.label || ''),
|
|
178
178
|
el('div', null, icon('cal', 'sm'), day(iso(s)) + ' to ' + day(iso(e)) + ' · ' + len + ' days', nd ? el('span', { class: 'ini-tlmv' }, (nd > 0 ? '+' : '') + nd + 'd') : null),
|
|
179
179
|
el('div', null, icon('up', 'sm'), (t.p || '0%') + ' done' + (t.st ? ' · ' + t.st : '')),
|
|
180
180
|
t.o ? el('div', null, icon('user', 'sm'), t.o) : null,
|
|
@@ -50,6 +50,13 @@ export function icon(name, cls) {
|
|
|
50
50
|
return c3ic(name, cls);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Replace a node's children, skipping null, undefined and false. The native replaceChildren() writes a null as the text
|
|
54
|
+
* "null", so a form whose optional line is off showed the word "null" (new-initiative-anchor-picker). */
|
|
55
|
+
export function put(node, ...kids) {
|
|
56
|
+
node.replaceChildren(...kids.flat().filter((k) => k != null && k !== false && k !== true));
|
|
57
|
+
return node;
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
/* ── controls ── */
|
|
54
61
|
/** A native button drawn as a console3 control. attrs: class (the console3 class), onclick, and any attribute. */
|
|
55
62
|
export function btn(attrs, ...kids) {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* workTlRows(ctx, items, onChanged) · iniTlRow(ctx, ini, kids, board) · iniTip(ini, board) rows for c3-ini-timeline.js
|
|
10
10
|
* loadItems(ctx) · dropItems(ctx) GET /api/boards/items, kept for a few seconds per board */
|
|
11
11
|
import { el } from './dom.js';
|
|
12
|
-
import { icon, chip, tag, st, note, kv, nrow, sect, list, lk, dateChip, ownerChip, ownerText, day, daysBetween, trunc, progChip, pill, plural } from './c3-ini-ui.js';
|
|
12
|
+
import { icon, chip, tag, st, note, kv, nrow, sect, list, lk, dateChip, ownerChip, ownerText, day, daysBetween, trunc, progChip, pill, plural, put } from './c3-ini-ui.js';
|
|
13
13
|
|
|
14
14
|
const S = (name, cat) => ({ name, cat });
|
|
15
15
|
/** The workflow templates (scripts/boards.js INI_TEMPLATES): name, kind, use and stages. */
|
|
@@ -96,7 +96,7 @@ export function openWorkSheet(ctx, w, all, onChanged) {
|
|
|
96
96
|
ctx.toast('"' + trunc(w.title, 40) + '" moved to ' + s + '.');
|
|
97
97
|
if (onChanged) onChanged(r.item);
|
|
98
98
|
}
|
|
99
|
-
body
|
|
99
|
+
put(body,
|
|
100
100
|
el('div', { class: 'chips2' }, srcTag(w), chip(icon(KIND_IC[w.kind] || 'check', 'sm'), w.kind), wkStatus(w), ini ? lk({ onclick: () => { sh.close(); ctx.openInitiative(ini); } }, icon('init', 'sm'), ini) : null),
|
|
101
101
|
kv([['Owner', ownerText(w.owner)], ['Dates', w.start || w.end ? (day(w.start) || 'No start') + ' to ' + (day(w.end) || 'no end') : 'No dates yet'], ['Progress', progChip(w.progress || 0, w.status === 'Done' ? 'ok' : 'acc')]]),
|
|
102
102
|
kids.length ? sect({ ic: 'bolt', title: 'In this epic', count: kids.length }, list(kids.map((k) => workRow(ctx, k, { all, epic: false, onChanged })))) : null,
|
|
@@ -43,7 +43,7 @@ import { openSheet, closeSheet } from './c3-sheet.js';
|
|
|
43
43
|
import { toast } from './c3-toast.js';
|
|
44
44
|
import {
|
|
45
45
|
btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, jiraLabel, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
|
|
46
|
-
stageChip, stepsMini, progChip, dateChip, list, empty, sect, card, nrow, crumbs, lh, segTabs, cols, hbs, hbr, kv, chgl, chgli, openMenu, closeMenu, CATS, PORTFOLIO,
|
|
46
|
+
stageChip, stepsMini, progChip, dateChip, list, empty, sect, card, nrow, crumbs, lh, segTabs, cols, hbs, hbr, kv, chgl, chgli, openMenu, closeMenu, CATS, PORTFOLIO, put
|
|
47
47
|
} from './c3-ini-ui.js';
|
|
48
48
|
import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf, catOf, kindOf, tplName, TOOL_META, toolLabel, toolIcon, loadItems, dropItems, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
49
49
|
import { timeline, tlInit, tlRange } from './c3-ini-timeline.js';
|
|
@@ -121,7 +121,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
121
121
|
loadTabModules();
|
|
122
122
|
const P = { loc: parseLoc(new URLSearchParams(location.search).get('init')), boards: null, today: null, seq: 0, dispose: null, ini: null };
|
|
123
123
|
const root = el('div', { class: 'calwrap ini', 'data-ini': 'page' });
|
|
124
|
-
pane
|
|
124
|
+
put(pane, root);
|
|
125
125
|
|
|
126
126
|
/* ── the api, with the board's ws ── */
|
|
127
127
|
const apiFor = (ws) => ({
|
|
@@ -174,7 +174,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
174
174
|
throw new Error('the repository is taking long to read. Reload in a moment.');
|
|
175
175
|
}
|
|
176
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
|
|
177
|
+
function status(text) { put(root, note(text)); root.firstChild.setAttribute('role', 'status'); root.firstChild.setAttribute('data-ini', 'status'); }
|
|
178
178
|
|
|
179
179
|
async function draw() {
|
|
180
180
|
const token = ++P.seq;
|
|
@@ -183,12 +183,12 @@ export function mountInitiatives(pane, opts) {
|
|
|
183
183
|
closeMenu();
|
|
184
184
|
if (!P.boards) status('Reading the boards');
|
|
185
185
|
let boards;
|
|
186
|
-
try { boards = await loadBoards(); } catch (e) { if (live()) root
|
|
186
|
+
try { boards = await loadBoards(); } catch (e) { if (live()) put(root, errLine('The boards could not be read: ' + e.message)); return; }
|
|
187
187
|
if (!live()) return;
|
|
188
188
|
root.dataset.iniView = P.loc.view;
|
|
189
189
|
if (P.loc.view === 'portfolio') return drawPortfolio(boards, token);
|
|
190
190
|
const b = findBoard(P.loc.board);
|
|
191
|
-
if (!b) { root
|
|
191
|
+
if (!b) { put(root, pathCrumbs(null), errLine('No board "' + P.loc.board + '" is in the portfolio. It may have been renamed or removed.')); return; }
|
|
192
192
|
if (P.loc.view === 'board') return drawBoard(b, token);
|
|
193
193
|
return drawIni(b, token);
|
|
194
194
|
}
|
|
@@ -219,12 +219,12 @@ export function mountInitiatives(pane, opts) {
|
|
|
219
219
|
await loadTabModules();
|
|
220
220
|
if (token !== P.seq) return;
|
|
221
221
|
const m = (ctx.row ? INITIATIVE_TABS : BOARD_TABS)[ctx.tab] || mount;
|
|
222
|
-
if (typeof m !== 'function') { host
|
|
222
|
+
if (typeof m !== 'function') { put(host, note('The ' + label + ' view is not built yet.')); return; }
|
|
223
223
|
try {
|
|
224
224
|
const d = await m(host, ctx);
|
|
225
225
|
if (typeof d === 'function') { if (token === P.seq) P.dispose = d; else d(); }
|
|
226
226
|
} catch (e) {
|
|
227
|
-
if (token === P.seq) host
|
|
227
|
+
if (token === P.seq) put(host, errLine('The ' + label + ' view could not draw: ' + String((e && e.message) || e)));
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
|
|
@@ -248,7 +248,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
248
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
249
|
: note('No new asks on any board.')),
|
|
250
250
|
];
|
|
251
|
-
root
|
|
251
|
+
put(root,
|
|
252
252
|
pathCrumbs(null, null, el('div', { class: 'chips2' }, mono(plural(boards.length, 'board') + ' · ' + plural(total, 'initiative') + ' · ' + plural(asks, 'new ask')),
|
|
253
253
|
pbtn('primary', 'New board', 'plus', { 'data-ini': 'new-board', onclick: () => openNewBoard() }))),
|
|
254
254
|
el('div', { class: 'calbody', 'data-ini': 'portfolio' },
|
|
@@ -278,7 +278,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
278
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
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
280
|
onLocked: (k) => lockedToast(makeCtx(boards[0] || {}, {}), k) });
|
|
281
|
-
tlHost
|
|
281
|
+
put(tlHost, 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
282
|
requestAnimationFrame(() => tlInit(tlHost));
|
|
283
283
|
}
|
|
284
284
|
function boardRow(b) {
|
|
@@ -311,7 +311,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
311
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
312
|
const host = el('div', { class: 'calbody', 'data-ini-panel': tab });
|
|
313
313
|
const def = TEMPLATES[b.defTpl] || TEMPLATES[b.template] || TEMPLATES.product;
|
|
314
|
-
root
|
|
314
|
+
put(root,
|
|
315
315
|
pathCrumbs(b, null, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctx0.showTab('rep') }),
|
|
316
316
|
pbtn('outline', 'Board settings', 'settings', { 'data-ini': 'board-settings', 'aria-haspopup': 'dialog', onclick: () => openBoardSettings(b) }))),
|
|
317
317
|
el('div', { class: 'calbody', 'data-ini-board-page': b.id },
|
|
@@ -331,7 +331,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
331
331
|
async function mountSummary(host, ctx) {
|
|
332
332
|
const b = ctx.board;
|
|
333
333
|
const r = await loadItems(ctx);
|
|
334
|
-
if (!r.ok) { host
|
|
334
|
+
if (!r.ok) { put(host, errLine('The board could not be read: ' + r.error + '.')); return; }
|
|
335
335
|
const inis = r.initiatives; const work = r.items; const open = inis.filter((i) => i.status !== 'done');
|
|
336
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
337
|
const withWork = new Set(work.map((w) => w.ini));
|
|
@@ -348,7 +348,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
348
348
|
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;
|
|
349
349
|
const goIni = (filter) => ctx.openBoard(b.id, 'initiatives', filter);
|
|
350
350
|
const rel = b.build && b.nextRelease;
|
|
351
|
-
host
|
|
351
|
+
put(host, cols(
|
|
352
352
|
[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')) },
|
|
353
353
|
glance.length ? list(glance.map(iniGlance), { 'data-ini': 'glance' }) : empty('No open initiatives yet. Accept an ask in Triage to start one.')),
|
|
354
354
|
sect({ ic: 'alert', title: 'Needs attention', count: risk.length, right: risk.length > att.length ? lk({ onclick: () => goIni({ view: 'risk' }) }, 'See all ' + risk.length) : null },
|
|
@@ -395,7 +395,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
395
395
|
const jiraN = usage && usage.ok ? usage.items.filter((w) => w.src === 'jira').length : 0;
|
|
396
396
|
const usingBuild = inis.filter((i) => wfOf(i, b).some(isBuildStage)).length;
|
|
397
397
|
const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project keys', placeholder: 'Project keys, e.g. IDPF, LB', 'data-ini': 'jira-key', oninput: (e) => { B.jira = e.target.value; } });
|
|
398
|
-
host
|
|
398
|
+
put(host,
|
|
399
399
|
note('Settings for the board. Workflows belong to each initiative; here you choose which templates the board offers and which one new initiatives start with.'),
|
|
400
400
|
checkbox(B.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'build' }, (e) => { B.build = e.target.checked; if (B.build) Object.keys(TEMPLATES).filter(templateBuilds).forEach((k) => B.offer.add(k)); render('[data-ini="build"]'); }),
|
|
401
401
|
B.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, jira), note('One or more Jira projects, separated by commas. Shows their epics, stories, tasks and bugs next to PMOS tasks, and adds the Sprints and Releases tabs (mirrored, read-only). Link each initiative to its Jira epics on the initiative page.'))
|
|
@@ -440,7 +440,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
440
440
|
function render(focusSel) {
|
|
441
441
|
const allowed = Object.keys(TEMPLATES).filter((k) => N.build || !templateBuilds(k));
|
|
442
442
|
if (!allowed.includes(N.tpl)) N.tpl = allowed[0];
|
|
443
|
-
host
|
|
443
|
+
put(host,
|
|
444
444
|
note('A board sits in the ' + PORTFOLIO + ' and holds initiatives. Each initiative picks its own workflow; the board only offers templates and a default.'),
|
|
445
445
|
lbl('BOARD NAME'), el('div', { class: 'form' }, name),
|
|
446
446
|
wsList.length > 1 ? el('label', { class: 'ini-field' }, lbl('WORKSPACE'), el('select', { 'data-ini': 'nb-ws', onchange: (e) => { N.ws = e.target.value; } }, ...wsList.map((w) => el('option', { value: w.id, selected: w.id === N.ws }, w.name || w.id)))) : null,
|
|
@@ -481,12 +481,31 @@ export function mountInitiatives(pane, opts) {
|
|
|
481
481
|
const host = el('div', { class: 'ini-form', 'data-ini': 'new-ini-form' });
|
|
482
482
|
const sh = openSheet({ title: 'New initiative · ' + b.name, icon: 'plus', body: host, wide: true });
|
|
483
483
|
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; } });
|
|
484
|
-
|
|
484
|
+
// The anchor is picked from what the OKR files and the health budgets declare (GET /api/boards/anchors), so a typed
|
|
485
|
+
// word the writer refuses (D59) cannot be sent. With nothing declared, or no answer, the field takes typed text.
|
|
486
|
+
const pick = el('select', { 'aria-label': 'Anchor', 'data-ini': 'ni-anchor', disabled: true, onchange: (e) => { F.anchor = e.target.value; } },
|
|
487
|
+
el('option', { value: '' }, 'Reading the key results and health budgets'));
|
|
488
|
+
const typed = el('input', { type: 'text', 'aria-label': 'Anchor', placeholder: 'A key result or health:<id>, e.g. health:tech', 'data-ini': 'ni-anchor', oninput: (e) => { F.anchor = e.target.value; } });
|
|
489
|
+
F.anchors = null;
|
|
490
|
+
const anchorField = () => (F.anchors && !F.anchors.length ? typed : pick);
|
|
491
|
+
const anchorNote = () => (F.anchors && !F.anchors.length
|
|
492
|
+
? 'No key result or health budget could be read' + (F.anchorsErr ? ' (' + F.anchorsErr + ')' : '') + '. Type an anchor declared in planning/okrs/.'
|
|
493
|
+
: 'Every initiative anchors to a key result or a health budget. PMOS refuses one without it.');
|
|
494
|
+
apiFor(b.ws).get('/api/boards/anchors', { board: b.id }).then((r) => {
|
|
495
|
+
F.anchors = r && r.ok && Array.isArray(r.anchors) ? r.anchors : [];
|
|
496
|
+
F.anchorsErr = r && r.ok ? r.error || '' : (r && r.error) || 'no answer';
|
|
497
|
+
const groups = [];
|
|
498
|
+
for (const a of F.anchors) { let g = groups.find((x) => x.name === a.group); if (!g) groups.push(g = { name: a.group, items: [] }); g.items.push(a); }
|
|
499
|
+
put(pick, el('option', { value: '' }, 'Pick a key result or a health budget'),
|
|
500
|
+
...groups.map((g) => el('optgroup', { label: g.name }, ...g.items.map((a) => el('option', { value: a.value, selected: a.value === F.anchor }, a.label)))));
|
|
501
|
+
pick.disabled = false;
|
|
502
|
+
if (host.isConnected) render();
|
|
503
|
+
});
|
|
485
504
|
function render(focusSel) {
|
|
486
|
-
host
|
|
505
|
+
put(host,
|
|
487
506
|
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.'),
|
|
488
507
|
lbl('TITLE'), el('div', { class: 'form' }, title),
|
|
489
|
-
lbl('ANCHOR'), el('div', { class: 'form' },
|
|
508
|
+
anchorField() === pick ? el('label', { class: 'ini-field' }, lbl('ANCHOR'), pick) : [lbl('ANCHOR'), el('div', { class: 'form' }, typed)], note(anchorNote()),
|
|
490
509
|
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)))),
|
|
491
510
|
lbl('WORKFLOW'), tplSeg(offer, F.tpl, (k) => { F.tpl = k; render('[data-ini-tpl="' + k + '"]'); }, 'Workflow', b.defTpl), tplCase(F.tpl),
|
|
492
511
|
b.build ? null : note(b.name + ' does not build a product, so workflows with Build and Deliver are not offered.'),
|
|
@@ -496,7 +515,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
496
515
|
}
|
|
497
516
|
async function create() {
|
|
498
517
|
const t = F.title.trim(); const a = F.anchor.trim();
|
|
499
|
-
if (!t || !a) { F.err = !t ? 'Give the initiative a title.' : 'Give it an anchor: a key result or a health budget.'; render(); (t ?
|
|
518
|
+
if (!t || !a) { F.err = !t ? 'Give the initiative a title.' : 'Give it an anchor: a key result or a health budget.'; render(); (t ? anchorField() : title).focus(); return; }
|
|
500
519
|
const api = apiFor(b.ws);
|
|
501
520
|
F.busy = true; F.err = ''; render();
|
|
502
521
|
const add = await api.post('/api/boards/asks/add', { board: b.id, line: t, source: 'New initiative, typed by you' });
|
|
@@ -537,7 +556,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
537
556
|
const rowsUi = stageRows(W, { current: row.stage, build: b.build !== false, onChange: () => { W.confirm = false; render(); } });
|
|
538
557
|
function render(focusSel) {
|
|
539
558
|
const m = mv();
|
|
540
|
-
host
|
|
559
|
+
put(host,
|
|
541
560
|
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.'),
|
|
542
561
|
el('div', { class: 'chips2', role: 'group', 'aria-label': 'Start from a template' }, mono('Start from a template ' + b.name + ' offers'),
|
|
543
562
|
...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',
|
|
@@ -620,7 +639,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
620
639
|
const rowsUi = stageRows(W, { build: b.build !== false, counts, onChange: () => render() });
|
|
621
640
|
function render(focusSel) {
|
|
622
641
|
const gone = removed().filter((n) => Number(counts[n]) > 0);
|
|
623
|
-
host
|
|
642
|
+
put(host,
|
|
624
643
|
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.'),
|
|
625
644
|
el('div', { class: 'chips2', role: 'group', 'aria-label': 'Start from a template' }, mono('Start from a template'),
|
|
626
645
|
...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))),
|
|
@@ -658,7 +677,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
658
677
|
const r = await apiFor(b.ws).get('/api/boards/initiative', { board: b.id, id });
|
|
659
678
|
if (token !== P.seq) return;
|
|
660
679
|
if (!r || !r.ok || !r.row) {
|
|
661
|
-
root
|
|
680
|
+
put(root, 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') + '.'));
|
|
662
681
|
return;
|
|
663
682
|
}
|
|
664
683
|
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 } };
|
|
@@ -685,7 +704,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
685
704
|
const tabs = INI_TAB_LIST.filter(([k]) => k !== 'tickets' || bb.build).map(([k, l, ic]) => [k, l, ic,
|
|
686
705
|
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]);
|
|
687
706
|
const host = el('div', { class: 'calbody', 'data-ini-panel': tab });
|
|
688
|
-
root
|
|
707
|
+
put(root,
|
|
689
708
|
pathCrumbs(bb, row, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctxI.showTab('rep') }))),
|
|
690
709
|
el('div', { class: 'calbody', 'data-ini-page': row.id },
|
|
691
710
|
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(' · ')),
|
|
@@ -705,7 +724,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
705
724
|
const sh = openSheet({ title: 'Jira epics · ' + row.id, icon: 'bolt', body: host });
|
|
706
725
|
function render() {
|
|
707
726
|
const input = el('input', { type: 'text', value: E.text, 'aria-label': 'Jira epic keys', placeholder: 'Epic keys, e.g. IDPF-2685, LB-120', 'data-ini': 'epics-input', oninput: (e) => { E.text = e.target.value; } });
|
|
708
|
-
host
|
|
727
|
+
put(host,
|
|
709
728
|
note('The Jira epics this initiative runs on. The daily Jira check reads each epic and the issues under it, read-only, and shows them as its Jira items.' + (jiraLabel(b) ? ' ' + b.name + ' mirrors Jira ' + jiraLabel(b) + '.' : '')),
|
|
710
729
|
row.epicsFrom === 'record' ? note('Now from the initiative record (jira: ' + row.epics.join(', ') + '). Saving here takes over from it.') : null,
|
|
711
730
|
el('div', { class: 'ini-field' }, lbl('JIRA EPICS'), el('div', { class: 'form' }, input)),
|
|
@@ -143,7 +143,7 @@ export function newInitLine(ctx) {
|
|
|
143
143
|
call.then((out) => { if (me !== run) return; slot.replaceChildren(out && out.ok && out.draft ? answer(text, out.draft) : el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), (out && out.error) || 'The draft did not answer.')); }); }, 650);
|
|
144
144
|
};
|
|
145
145
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) start(input.value); });
|
|
146
|
-
const mic = c3ib('Speak is off on this line: on-device dictation runs in Notes, in the
|
|
146
|
+
const mic = c3ib('Speak is off on this line: on-device dictation runs in Notes, in the classic app (/classic.html). Type the line here.', 'mic', { disabled: true });
|
|
147
147
|
const spark = c3ib('Start: an agent runs the intake', 'spark', { 'data-a': 'c3nigo', 'data-c3pg': 'new-init-go', onClick: () => start(input.value) }, 'pri');
|
|
148
148
|
return el('div', null, el('div', { class: 'c3add' }, el('div', { class: 'form' }, input, mic, spark),
|
|
149
149
|
el('div', { class: 'chips2' }, el('span', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, 'try'),
|
|
@@ -570,9 +570,9 @@ export function mountSettings(host, ctx = {}) {
|
|
|
570
570
|
return [group('New sessions', [
|
|
571
571
|
extRow('sess.cli', seg('Start with', [['claude', 'Claude Code'], ['shell', 'A shell']], d.cli, (v) => setDef({ cli: v }, 'sess.cli', { cli: d.cli })), { scope: 'computer', note: 'Kept in this browser, as the dock keeps it.' }),
|
|
572
572
|
extRow('sess.tier', seg('Model', [['auto', 'Auto'], ['fast', 'Fast'], ['balanced', 'Balanced'], ['deep', 'Deep']], d.tier, (v) => setDef({ tier: v }, 'sess.tier', { tier: d.tier })), { scope: 'computer' }),
|
|
573
|
-
extRow('sess.profile', profileSelect('c3set-sess-profile'), { scope: 'computer', forId: 'c3set-sess-profile', note: profs.length ? null : p ? 'No profile was found. Make one in the
|
|
573
|
+
extRow('sess.profile', profileSelect('c3set-sess-profile'), { scope: 'computer', forId: 'c3set-sess-profile', note: profs.length ? null : p ? 'No profile was found. Make one in the classic app\'s Settings, Profiles (/classic.html).' : 'Reading profiles.' }),
|
|
574
574
|
lockedRow('sess.flags', 'Launch flags', 'Flags for the claude command, such as a permission mode.', 'Set when you start a session', 'computer')], 'term'),
|
|
575
|
-
profs.length ? sect('Profiles', [list(profRows), help('A new profile is made in the
|
|
575
|
+
profs.length ? sect('Profiles', [list(profRows), help('A new profile is made in the classic app\'s Settings, Profiles (/classic.html).')], { ic: 'user', count: profs.length }) : null,
|
|
576
576
|
groupOf('sessions', 'Housekeeping', 'term'),
|
|
577
577
|
sect('Running now' + (M.running ? ' · ' + run.length + (max ? ' of ' + max : '') : ''), [M.running ? (run.length ? list(runRows) : fold(null, 'No sessions running.')) : fold(null, 'Reading the sessions.'),
|
|
578
578
|
help('Close a session from the dock at the bottom of the page.')], { ic: 'term' }),
|
|
@@ -136,7 +136,7 @@ function toggleMore() {
|
|
|
136
136
|
item(S.theme === 'dark' ? 'sun' : 'moon', S.theme === 'dark' ? 'Light theme' : 'Dark theme', () => setTheme(S.theme === 'dark' ? 'light' : 'dark')),
|
|
137
137
|
((b) => { b.firstChild.replaceWith(gearIcon('sm')); return b; })(item('info', 'Settings', () => showTab('settings', true), false, { 'data-a': 'settings' })),
|
|
138
138
|
item('term', S.dockOpen ? 'Fold the dock' : 'Open the dock', () => { S.dockOpen = !S.dockOpen; syncDock(); }, false, { 'data-a': 'dockt' }, 'Ctrl J'),
|
|
139
|
-
el('a', { class: 'c3pg-mi', role: 'menuitem', href: '/' }, c3ic('term', 'sm'), el('span', null, 'Open the
|
|
139
|
+
el('a', { class: 'c3pg-mi', role: 'menuitem', href: '/classic.html' }, c3ic('term', 'sm'), el('span', null, 'Open the classic app')));
|
|
140
140
|
S.root.append(S.menu); // on the page root with position:fixed, so no ancestor's overflow, transform or stacking order can hide it
|
|
141
141
|
S.placeMenu = () => { const r = S.moreBtn.getBoundingClientRect(); const w = S.menu.offsetWidth || 220; S.menu.style.top = Math.round(r.bottom + 6) + 'px'; S.menu.style.left = Math.round(Math.max(8, Math.min(r.right - w, innerWidth - w - 8))) + 'px'; S.menu.style.maxHeight = Math.max(160, innerHeight - r.bottom - 14) + 'px'; };
|
|
142
142
|
S.placeMenu(); addEventListener('resize', S.placeMenu); addEventListener('scroll', S.placeMenu, true);
|
|
@@ -425,7 +425,7 @@ function dock() {
|
|
|
425
425
|
});
|
|
426
426
|
S.dockEl = S.dock.side;
|
|
427
427
|
// a session opens in its own terminal sheet on this page (c3-session-term.js), not in the main app
|
|
428
|
-
window.addEventListener('pmos:open-session', (e) => { const id = e.detail && e.detail.id; if (!id) { location.assign('/'); return; } import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: e.detail.name })); });
|
|
428
|
+
window.addEventListener('pmos:open-session', (e) => { const id = e.detail && e.detail.id; if (!id) { location.assign('/classic.html'); return; } import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: e.detail.name })); });
|
|
429
429
|
return [S.dock.side];
|
|
430
430
|
}
|
|
431
431
|
/* The dock is open on the Briefing and folded away on the other tabs. A folded dock leaves no rail (PM, 2026-10-12): the header's Sessions button, Ctrl J and the More menu reach it. */
|
|
@@ -491,7 +491,7 @@ function showInit(id) {
|
|
|
491
491
|
S.initHosts[k].hidden = k !== id;
|
|
492
492
|
if (k === id && !S.mounted['init-' + k] && S.initRows) {
|
|
493
493
|
S.mounted['init-' + k] = true;
|
|
494
|
-
const ctx = { ...initiativesViewCtx({ repo: ws.root || null, ws: S.ws, rows: S.initRows.rows, sig: S.initRows.sig, openInitiative: () => location.assign('/'), setState: () => {} }), strategy: S.initRows.strategy, plan: planNode(sc(OWN.has(k) ? 'init-' + k : 'init')), links: crossLinks((t) => showTab(t, true)) };
|
|
494
|
+
const ctx = { ...initiativesViewCtx({ repo: ws.root || null, ws: S.ws, rows: S.initRows.rows, sig: S.initRows.sig, openInitiative: () => location.assign('/classic.html'), setState: () => {} }), strategy: S.initRows.strategy, plan: planNode(sc(OWN.has(k) ? 'init-' + k : 'init')), links: crossLinks((t) => showTab(t, true)) };
|
|
495
495
|
if (k === 'lifecycle' || OWN.has(k)) mount(S.initHosts[k], Object.freeze(ctx));
|
|
496
496
|
else { const view = el('div'); S.initHosts[k].replaceChildren(ctx.plan, view); mount(view, Object.freeze({ ...ctx, plan: null })); }
|
|
497
497
|
}
|
|
@@ -115,7 +115,7 @@ function render(V) {
|
|
|
115
115
|
const reading = !D;
|
|
116
116
|
const none = !reading && !all.length;
|
|
117
117
|
const head = reading ? { icon: 'check', h: 'Reading the Jira tickets', s: 'Tickets live in Jira. The agent proposes, you accept.', ring: [0, 1] }
|
|
118
|
-
: none ? { icon: 'check', h: 'No Jira tickets read yet', s: D.err || 'The agent has not read Jira yet. A read from My work in the
|
|
118
|
+
: none ? { icon: 'check', h: 'No Jira tickets read yet', s: D.err || 'The agent has not read Jira yet. A read from My work in the classic app (/classic.html) fills this view.', ring: [0, 1] }
|
|
119
119
|
: { icon: 'check', h: T.length + ' Jira ticket' + (T.length === 1 ? '' : 's') + ', ' + bad.length + ' late or at risk', s: 'Tickets live in Jira. The agent proposes, you accept.', ring: [done.length, T.length] };
|
|
120
120
|
|
|
121
121
|
const left = [];
|
|
@@ -2215,3 +2215,6 @@
|
|
|
2215
2215
|
.c3.c3pg .c3set .st-hd input[type=text]{flex:1 1 100%;width:auto}
|
|
2216
2216
|
.c3.c3pg .c3set .st-hd select{flex:1 1 0;min-width:0}
|
|
2217
2217
|
}
|
|
2218
|
+
/* the dock's note under the branch list and its empty detail (c3-dock.js) */
|
|
2219
|
+
.c3dock .dk-more { font-size: var(--fs-1); padding: 4px; }
|
|
2220
|
+
.c3dock .dk-empty { font-size: var(--fs-2); }
|
|
@@ -122,7 +122,7 @@ export const COPY = Object.freeze({
|
|
|
122
122
|
empty: 'Nothing here.', noJobGroup: 'No job yet', jobs: 'Jobs to be done', noJobs: 'No jobs yet.',
|
|
123
123
|
addJob: 'Add a job', addJobPlaceholder: 'when I plan a release, I want honest dates, so Dana can trust them',
|
|
124
124
|
groupJob: 'Group by job to be done', groupTime: 'Group by time', loading: 'Reading…',
|
|
125
|
-
micOff: 'Speak is off on this line: on-device dictation runs in Notes, in the
|
|
125
|
+
micOff: 'Speak is off on this line: on-device dictation runs in Notes, in the classic app (/classic.html). Type the item here.',
|
|
126
126
|
jobsNote: 'The count is every item in the job, across all views. Tap a job to edit or delete it.',
|
|
127
127
|
legend: ['open', 'done'], readError: 'Your items could not be read.', saveError: 'Nothing was saved.',
|
|
128
128
|
removed: 'Removed.', undo: 'Undo', restored: 'Restored.', tooLate: 'Too late to undo.', drop: 'Drop',
|