create-pmos 0.186.0 → 0.187.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/scripts/workflow-state.js +2 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-ib-triage-backlog-test.js +20 -13
- package/workspace/c3-ini-life-test.js +167 -0
- package/workspace/c3-initiatives-test.js +6 -6
- package/workspace/c3-phone-test.js +67 -16
- package/workspace/c3-quiet-test.js +2 -1
- package/workspace/guide/initiative-boards.md +37 -2
- package/workspace/guide/phone-companion.md +19 -4
- package/workspace/lib/engine/boards.js +73 -18
- package/workspace/lib/engine/workflow-state.js +2 -1
- package/workspace/lib/initiative-rows.js +2 -2
- package/workspace/lib/routes-boards.js +119 -6
- package/workspace/lib/routes-meeting-focus.js +19 -7
- package/workspace/lib/vendor/scripts/workflow-state.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +82 -17
- package/workspace/public/pm/c3-demo-focus.js +39 -1
- package/workspace/public/pm/c3-focus.css +53 -0
- package/workspace/public/pm/c3-focus.js +188 -44
- package/workspace/public/pm/c3-ib-initiatives.js +127 -60
- package/workspace/public/pm/c3-ib-items.js +2 -2
- package/workspace/public/pm/c3-ini-life.css +32 -0
- package/workspace/public/pm/c3-ini-life.js +379 -0
- package/workspace/public/pm/c3-ini-ui.js +12 -7
- package/workspace/public/pm/c3-initiatives.js +14 -4
- package/workspace/public/pm/c3-phone.css +3 -1
- package/workspace/public/pm/c3-phone.js +28 -8
- package/workspace/server.js +7 -2
- package/workspace/test-lists/c3-ini-life.json +9 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/* pm/c3-ini-life.js — On hold, Cancelled, Merge and Archive for an initiative (c3-initiative-lifecycle; mockup v2, 34-initiatives.js
|
|
2
|
+
* "LIFECYCLE", approved 2026-10-13; planning/prd/c3-initiative-lifecycle-prd.md).
|
|
3
|
+
*
|
|
4
|
+
* Stage says where the work is in its workflow. Status says how it is going: On track, At risk or Late while it is active, or a stop
|
|
5
|
+
* the PM chose: On hold, Cancelled. Done is the end of the workflow. Archive is not a status: it takes an initiative out of every
|
|
6
|
+
* board list and count and keeps it whole, so Restore brings it back as it was. Merge archives a duplicate as "Merged into <id>"
|
|
7
|
+
* after moving its Jira epic links and PMOS tasks.
|
|
8
|
+
*
|
|
9
|
+
* rowMenu(anchor, L, r) the row's "…" menu: Move to a stage, Status, Tidy up
|
|
10
|
+
* pageMenu(anchor, L, r) the initiative page's "…" menu: Status and Tidy up
|
|
11
|
+
* lifeBanner(L, r) the page banner of an on-hold, cancelled or archived initiative, or null
|
|
12
|
+
* lifeChips(r) · archivedChips(r) · dupChip(L, r) the chips a row shows
|
|
13
|
+
* restoreBtn(L, r) Restore, or Undo merge
|
|
14
|
+
* L = { ctx, done(change) }: ctx is the tab or page ctx of c3-initiatives.js; done() is called after a write lands, with
|
|
15
|
+
* { kind, id, status?, archived? } so the caller can read again until the answer shows it.
|
|
16
|
+
*
|
|
17
|
+
* WRITES. Hold, cancel, resume and reopen are D70 lifecycle lines (POST /api/lifecycle, a PM press). Archive, restore, merge and
|
|
18
|
+
* not-a-duplicate are board-ledger lines (POST /api/boards/archive, /merge, /notdup). Moving to another board is POST
|
|
19
|
+
* /api/boards/assign. Every action's toast has Undo, which writes the opposite line. Nothing here writes a record or Jira. */
|
|
20
|
+
import { el } from './dom.js';
|
|
21
|
+
import { btn, icon, pill, lk, pbtn, chip, st, lbl, statusChip, sw, openMenu, closeMenu, put, trunc, day, plural, card, chgl, chgli } from './c3-ini-ui.js';
|
|
22
|
+
import { openSheet, closeSheet } from './c3-sheet.js';
|
|
23
|
+
import { toast } from './c3-toast.js';
|
|
24
|
+
import { wfOf } from './c3-ini-work.js';
|
|
25
|
+
|
|
26
|
+
/** Link c3-ini-life.css once, from this module's own folder. */
|
|
27
|
+
export function ensureLifeCss() {
|
|
28
|
+
if (typeof document === 'undefined' || document.querySelector('link[data-c3-life]')) return;
|
|
29
|
+
const l = document.createElement('link');
|
|
30
|
+
l.rel = 'stylesheet'; l.href = new URL('./c3-ini-life.css', import.meta.url).href; l.setAttribute('data-c3-life', '');
|
|
31
|
+
document.head.appendChild(l);
|
|
32
|
+
}
|
|
33
|
+
ensureLifeCss();
|
|
34
|
+
|
|
35
|
+
export const ACTIVE = ['on track', 'at risk', 'late'];
|
|
36
|
+
export const isActive = (r) => !!r && ACTIVE.includes(r.status);
|
|
37
|
+
const BACK = [['two', 'In 2 weeks', 14], ['month', 'In a month', 30], ['quarter', 'Next quarter', 80], ['none', 'No date', null]];
|
|
38
|
+
const WHY = [['needed', 'No longer needed'], ['replaced', 'Replaced by another initiative'], ['worth', 'Not worth it now'], ['kill', 'Hit a kill condition'], ['other', 'Other']];
|
|
39
|
+
const AWHY = [['unrelated', 'Not related to this board'], ['stale', 'Old idea, never started'], ['dup', 'Duplicate'], ['other', 'Other']];
|
|
40
|
+
const LC = { 'on hold': 'on-hold', cancelled: 'cancelled' };
|
|
41
|
+
|
|
42
|
+
function addDays(iso, n) {
|
|
43
|
+
const [y, m, d] = String(iso || '').split('-').map(Number);
|
|
44
|
+
const t = new Date(Date.UTC(y || 2026, (m || 1) - 1, d || 1) + n * 864e5);
|
|
45
|
+
return t.toISOString().slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
/* A sheet's line of text. Not `.ini-note`: the quiet pages hide those, and a sheet's sentences are what it asks. */
|
|
48
|
+
const inote = (...kids) => el('div', { class: 'dim il-note' }, ...kids);
|
|
49
|
+
const dayOf = (v) => (v ? day(String(v).slice(0, 10)) : '');
|
|
50
|
+
const errText = (r) => (r && r.error) || 'no answer';
|
|
51
|
+
const pct = (r) => Math.round((Number(r.progress) || 0) * 100) + '% done';
|
|
52
|
+
|
|
53
|
+
/* ── the writes ── */
|
|
54
|
+
async function lifecycle(L, id, state, reason, back) {
|
|
55
|
+
const body = { initiative_id: id, state, reason: reason || '' };
|
|
56
|
+
if (back) body.back = back;
|
|
57
|
+
return L.ctx.api.post('/api/lifecycle', body);
|
|
58
|
+
}
|
|
59
|
+
const archive = (L, id, archived, reason) => L.ctx.api.post('/api/boards/archive', { board: L.ctx.board.id, id, archived, ...(reason ? { reason } : {}) });
|
|
60
|
+
const merge = (L, id, into, epics, tasks) => L.ctx.api.post('/api/boards/merge', { board: L.ctx.board.id, id, into, epics, tasks });
|
|
61
|
+
const failed = (what, r) => { toast({ text: what + ' did not save: ' + errText(r) + '.', tone: 'error' }); return false; };
|
|
62
|
+
|
|
63
|
+
/** The lifecycle line that puts `r` back as it was before this change (Undo). */
|
|
64
|
+
function priorState(r) {
|
|
65
|
+
if (r.status === 'on hold') return { state: 'on-hold', reason: (r.stopped && r.stopped.reason) || '', back: (r.stopped && r.stopped.back) || null };
|
|
66
|
+
if (r.status === 'cancelled') return { state: 'cancelled', reason: (r.stopped && r.stopped.reason) || '', back: null };
|
|
67
|
+
return { state: 'active', reason: '', back: null };
|
|
68
|
+
}
|
|
69
|
+
const statusAfter = (state) => (state === 'on-hold' ? 'on hold' : state === 'cancelled' ? 'cancelled' : null);
|
|
70
|
+
|
|
71
|
+
async function setState(L, r, state, reason, back, words) {
|
|
72
|
+
const prev = priorState(r);
|
|
73
|
+
const res = await lifecycle(L, r.id, state, reason, back);
|
|
74
|
+
if (!res || !res.ok) return failed('The change', res);
|
|
75
|
+
closeSheet();
|
|
76
|
+
L.done({ kind: 'status', id: r.id, status: statusAfter(state) });
|
|
77
|
+
toast(words, async () => {
|
|
78
|
+
const u = await lifecycle(L, r.id, prev.state, prev.reason, prev.back);
|
|
79
|
+
if (!u || !u.ok) { failed('The undo', u); return; }
|
|
80
|
+
L.done({ kind: 'status', id: r.id, status: statusAfter(prev.state) || r.status });
|
|
81
|
+
});
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
const resume = (L, r) => setState(L, r, 'active', '', null, r.id + ' is active again, in ' + r.stage);
|
|
85
|
+
const reopen = (L, r) => setState(L, r, 'active', '', null, r.id + ' is reopened, in ' + r.stage);
|
|
86
|
+
|
|
87
|
+
async function restore(L, r) {
|
|
88
|
+
const a = r.archived || {};
|
|
89
|
+
const res = await archive(L, r.id, false);
|
|
90
|
+
if (!res || !res.ok) return failed('The restore', res);
|
|
91
|
+
L.done({ kind: 'archive', id: r.id, archived: false });
|
|
92
|
+
toast(a.into ? 'The merge of ' + r.id + ' is undone. What moved went back.' : r.id + ' restored', async () => {
|
|
93
|
+
const u = a.into ? await merge(L, r.id, a.into, (a.moved && a.moved.epics) || [], (a.moved && a.moved.tasks) || []) : await archive(L, r.id, true, a.reason || '');
|
|
94
|
+
if (!u || !u.ok) { failed('The undo', u); return; }
|
|
95
|
+
L.done({ kind: 'archive', id: r.id, archived: true });
|
|
96
|
+
});
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ── the sheet frame ── */
|
|
101
|
+
const field = (label, ...kids) => el('div', { class: 'ini-field' }, lbl(label), ...kids);
|
|
102
|
+
function pills(key, opts, cur, onPick) {
|
|
103
|
+
return el('div', { class: 'chips2', role: 'radiogroup', 'aria-label': key },
|
|
104
|
+
...opts.map(([v, l]) => pill({ role: 'radio', 'aria-checked': String(cur === v), 'aria-pressed': String(cur === v), 'data-il-set': key, 'data-v': v, onclick: () => onPick(v) }, l)));
|
|
105
|
+
}
|
|
106
|
+
const happens = (lines) => card({ ic: 'info', title: 'What happens' }, chgl(lines.filter(Boolean).map(([ic, t]) => chgli(ic, t))));
|
|
107
|
+
const jiraLine = (L) => (L.ctx.board.build ? ['jira', 'Its Jira epics are not changed. PMOS records this; you decide in Jira.'] : null);
|
|
108
|
+
function foot(sh, label, onDo, attrs, busy) {
|
|
109
|
+
return el('div', { class: 'chips2 il-foot' }, pbtn('ghost', 'Not now', null, { 'data-il': 'not-now', onclick: () => sh.close() }),
|
|
110
|
+
pbtn('primary', busy ? 'Saving' : label, null, { 'data-il-do': attrs, disabled: !!busy, onclick: onDo }));
|
|
111
|
+
}
|
|
112
|
+
function textBox(label, ph, value, onInput, key) {
|
|
113
|
+
return el('textarea', { rows: '2', 'aria-label': label, placeholder: ph, 'data-il-text': key, oninput: (e) => onInput(e.target.value) }, value || '');
|
|
114
|
+
}
|
|
115
|
+
const head = (r) => inote(el('b', null, r.title || r.id), ' · ' + (r.stage || 'no stage') + ' · ' + pct(r));
|
|
116
|
+
function sheet(title, ic, wide) {
|
|
117
|
+
const host = el('div', { class: 'ini-form il-sheet', 'data-il': 'sheet' });
|
|
118
|
+
const sh = openSheet({ title, icon: ic, body: host, wide: !!wide });
|
|
119
|
+
return { host, sh };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ── Put on hold ── */
|
|
123
|
+
export function openHold(L, r) {
|
|
124
|
+
const F = { reason: '', back: 'month', busy: false };
|
|
125
|
+
const { host, sh } = sheet('Put on hold · ' + r.id, 'moon');
|
|
126
|
+
const ta = textBox('Why is it paused', 'For example: waiting for the Q1 budget decision', '', (v) => { F.reason = v; }, 'reason');
|
|
127
|
+
const backDay = () => { const b = BACK.find((x) => x[0] === F.back); return b && b[2] != null ? addDays(L.ctx.today, b[2]) : null; };
|
|
128
|
+
function render() {
|
|
129
|
+
const d = backDay();
|
|
130
|
+
put(host, head(r), field('WHY IS IT PAUSED', el('div', { class: 'form' }, ta)), field('CHECK BACK', pills('back', BACK, F.back, (v) => { F.back = v; render(); })),
|
|
131
|
+
happens([['check', 'Keeps its stage (' + (r.stage || 'none') + '), its work and its history'],
|
|
132
|
+
['moon', 'Shows as On hold. It leaves Now and next and the at-risk counts, and never turns Late while paused'],
|
|
133
|
+
['spark', 'The agent stops driving it'],
|
|
134
|
+
['bell', d ? 'The check-back date, ' + day(d) + ', shows on its row and its page' : 'No check-back date: it stays paused until you resume it'],
|
|
135
|
+
jiraLine(L)]),
|
|
136
|
+
foot(sh, 'Put on hold', go, 'hold', F.busy));
|
|
137
|
+
}
|
|
138
|
+
async function go() {
|
|
139
|
+
F.busy = true; render();
|
|
140
|
+
const ok = await setState(L, r, 'on-hold', F.reason.trim(), backDay(), r.id + ' is on hold');
|
|
141
|
+
if (!ok) { F.busy = false; if (host.isConnected) render(); }
|
|
142
|
+
}
|
|
143
|
+
render(); ta.focus();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ── Cancel ── */
|
|
147
|
+
export function openCancel(L, r) {
|
|
148
|
+
const F = { why: '', note: '', busy: false };
|
|
149
|
+
const { host, sh } = sheet('Cancel · ' + r.id, 'x');
|
|
150
|
+
const ta = textBox('Note', 'What did we learn, and who was told', '', (v) => { F.note = v; }, 'note');
|
|
151
|
+
function render() {
|
|
152
|
+
put(host, head(r), field('WHY', pills('why', WHY, F.why, (v) => { F.why = v; render(); })),
|
|
153
|
+
F.why === 'replaced' ? inote('If the other initiative does the same thing, ', lk({ 'data-il': 'merge-instead', onclick: () => openMerge(L, r) }, 'merge it instead'), '. Its work then moves over.') : null,
|
|
154
|
+
field('NOTE (OPTIONAL)', el('div', { class: 'form' }, ta)),
|
|
155
|
+
happens([['x', 'Shows as Cancelled. The Summary and the counts treat it as cancelled, not done'],
|
|
156
|
+
['eye', 'Hidden from every view until the Status filter asks for Cancelled'],
|
|
157
|
+
['check', 'Keeps its stage (' + (r.stage || 'none') + '), its work and its history'],
|
|
158
|
+
['spark', 'The agent stops driving it'],
|
|
159
|
+
['undo', 'Reopen it any time; it comes back at the same stage'],
|
|
160
|
+
jiraLine(L)]),
|
|
161
|
+
F.why ? foot(sh, 'Cancel initiative', go, 'cancel', F.busy) : el('div', { class: 'chips2 il-foot' }, inote('Pick a reason to cancel.')));
|
|
162
|
+
}
|
|
163
|
+
async function go() {
|
|
164
|
+
const why = (WHY.find((w) => w[0] === F.why) || [null, ''])[1];
|
|
165
|
+
F.busy = true; render();
|
|
166
|
+
const ok = await setState(L, r, 'cancelled', why + (F.note.trim() ? ': ' + F.note.trim() : ''), null, r.id + ' is cancelled');
|
|
167
|
+
if (!ok) { F.busy = false; if (host.isConnected) render(); }
|
|
168
|
+
}
|
|
169
|
+
render();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ── Merge ── */
|
|
173
|
+
const words = (s) => new Set(String(s || '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter((w) => w.length > 3));
|
|
174
|
+
/** The three initiatives most like `r`: shared title words, the same anchor (+2), the duplicate hint (+5). */
|
|
175
|
+
export function similar(r, rows) {
|
|
176
|
+
const w = words(r.title);
|
|
177
|
+
return rows.filter((x) => x.id !== r.id && !x.archived).map((x) => {
|
|
178
|
+
const xw = words(x.title); let n = 0; w.forEach((k) => { if (xw.has(k)) n++; });
|
|
179
|
+
return { x, n: n + (x.anchor && x.anchor === r.anchor ? 2 : 0) + (x.id === r.dupOf ? 5 : 0) };
|
|
180
|
+
}).filter((s) => s.n >= 2).sort((a, c) => c.n - a.n).slice(0, 3).map((s) => s.x);
|
|
181
|
+
}
|
|
182
|
+
async function boardRows(L) {
|
|
183
|
+
const out = [];
|
|
184
|
+
for (let off = 0; off < 500; off += 100) {
|
|
185
|
+
const r = await L.ctx.api.get('/api/boards/backlog', { board: L.ctx.board.id, view: 'all', sort: 'id', offset: off, limit: 100 });
|
|
186
|
+
if (!r || !r.ok) break;
|
|
187
|
+
out.push(...(r.rows || []));
|
|
188
|
+
if (out.length >= (r.total || 0)) break;
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
export function openMerge(L, r, into) {
|
|
193
|
+
const F = { pick: into || null, rows: null, tasks: null, moveEpics: true, moveTasks: true, busy: false };
|
|
194
|
+
const { host, sh } = sheet('Merge ' + r.id, 'branch', true);
|
|
195
|
+
Promise.all([boardRows(L), L.ctx.api.get('/api/boards/items', { board: L.ctx.board.id })]).then(([rows, it]) => {
|
|
196
|
+
F.rows = rows;
|
|
197
|
+
F.tasks = it && it.ok ? (it.items || []).filter((w) => w.src === 'pmos' && w.ini === r.id).map((w) => w.uid) : [];
|
|
198
|
+
if (!F.pick) { const s = similar(r, rows)[0]; F.pick = s ? s.id : null; }
|
|
199
|
+
if (host.isConnected) render();
|
|
200
|
+
});
|
|
201
|
+
function opt(x, k) {
|
|
202
|
+
const on = F.pick === x.id;
|
|
203
|
+
return btn({ class: 'opt' + (k === 0 ? ' rec' : ''), role: 'radio', 'aria-pressed': String(on), 'aria-checked': String(on), 'data-il-set': 'pick', 'data-v': x.id, onclick: () => { F.pick = x.id; render(); } },
|
|
204
|
+
el('span', { class: 'pk' }, icon('check')), el('span', { class: 'rk' }, k === 0 ? [icon('spark'), 'Most alike'] : null),
|
|
205
|
+
el('b', null, x.id + ' ' + (x.title || '')), el('div', { class: 'fx' }, el('span', null, x.stage || ''), el('span', null, x.anchor || 'no anchor'), el('span', null, x.owner || 'No owner')));
|
|
206
|
+
}
|
|
207
|
+
function render() {
|
|
208
|
+
if (!F.rows) { put(host, inote('Reading the initiatives of ' + L.ctx.board.name)); return; }
|
|
209
|
+
const sim = similar(r, F.rows);
|
|
210
|
+
const others = F.rows.filter((x) => x.id !== r.id && !x.archived && !sim.includes(x));
|
|
211
|
+
const tgt = F.rows.find((x) => x.id === F.pick) || null;
|
|
212
|
+
const epics = L.ctx.board.build ? (r.epics || []) : [];
|
|
213
|
+
const mv = (key, ic, label, n, hint, on, set) => el('label', { class: 'chips2 il-move' },
|
|
214
|
+
el('input', { type: 'checkbox', 'data-il-move': key, checked: !!(on && n), disabled: !n, onchange: (e) => set(e.target.checked) }),
|
|
215
|
+
el('span', null, icon(ic, 'sm'), ' ', el('b', null, label), ' ', el('span', { class: 'bdg g' }, String(n)), hint ? [el('br'), el('span', { class: 'dim il-hint' }, hint)] : null));
|
|
216
|
+
put(host,
|
|
217
|
+
inote('Merging moves what you pick from ', el('b', null, r.id + ' ' + (r.title || '')), ' into the initiative you keep. ' + r.id + ' then goes to the Archive as Merged.'),
|
|
218
|
+
field('KEEP WHICH INITIATIVE', sim.length ? el('div', { class: 'opts', role: 'radiogroup', 'aria-label': 'Keep which initiative' }, ...sim.map(opt)) : null,
|
|
219
|
+
el('label', { class: 'chips2' }, el('span', { class: 'dim il-hint' }, sim.length ? 'Or pick another' : 'Pick the initiative to keep'),
|
|
220
|
+
el('select', { 'data-il-pick': '', 'aria-label': 'Pick another initiative', onchange: (e) => { if (e.target.value) { F.pick = e.target.value; render(); } } },
|
|
221
|
+
el('option', { value: '' }, 'Choose…'), ...others.map((x) => el('option', { value: x.id, selected: F.pick === x.id && !sim.includes(x) }, x.id + ' ' + trunc(x.title, 50)))))),
|
|
222
|
+
tgt ? [
|
|
223
|
+
field('WHAT MOVES TO ' + tgt.id,
|
|
224
|
+
L.ctx.board.build ? mv('epics', 'jira', 'Jira epic links', epics.length, 'The link moves. Nothing changes in Jira.', F.moveEpics, (v) => { F.moveEpics = v; }) : null,
|
|
225
|
+
mv('tasks', 'todo', 'PMOS tasks', (F.tasks || []).length, '', F.moveTasks, (v) => { F.moveTasks = v; })),
|
|
226
|
+
happens([['check', tgt.id + ' keeps its title, stage (' + (tgt.stage || 'none') + '), owner and dates'],
|
|
227
|
+
['clip', r.id + ' goes to the Archive as Merged into ' + tgt.id + ', with its history'],
|
|
228
|
+
['doc', 'Documents, asks and notes stay with ' + r.id + ' and open from it'],
|
|
229
|
+
['undo', 'Undo the merge from ' + r.id + ' in the Archive; what moved goes back']]),
|
|
230
|
+
r.dupOf === tgt.id ? inote(lk({ 'data-il': 'not-dup', onclick: notDup }, 'Not a duplicate'), ': the hint on ' + r.id + ' stops.') : null,
|
|
231
|
+
foot(sh, 'Merge into ' + tgt.id, () => go(tgt, epics), 'merge', F.busy)]
|
|
232
|
+
: inote('Pick the initiative to keep.'));
|
|
233
|
+
}
|
|
234
|
+
async function notDup() {
|
|
235
|
+
const res = await L.ctx.api.post('/api/boards/notdup', { board: L.ctx.board.id, id: r.id, of: r.dupOf });
|
|
236
|
+
if (!res || !res.ok) { failed('Not a duplicate', res); return; }
|
|
237
|
+
sh.close(); L.done({ kind: 'notdup', id: r.id });
|
|
238
|
+
toast(r.id + ' is not marked as a duplicate of ' + r.dupOf + ' again');
|
|
239
|
+
}
|
|
240
|
+
async function go(tgt, epics) {
|
|
241
|
+
F.busy = true; render();
|
|
242
|
+
const e = F.moveEpics ? epics : []; const t = F.moveTasks ? (F.tasks || []) : [];
|
|
243
|
+
const res = await merge(L, r.id, tgt.id, e, t);
|
|
244
|
+
if (!res || !res.ok) { F.busy = false; if (host.isConnected) render(); failed('The merge', res); return; }
|
|
245
|
+
sh.close();
|
|
246
|
+
L.done({ kind: 'archive', id: r.id, archived: true });
|
|
247
|
+
toast(r.id + ' merged into ' + tgt.id, async () => {
|
|
248
|
+
const u = await archive(L, r.id, false);
|
|
249
|
+
if (!u || !u.ok) { failed('The undo', u); return; }
|
|
250
|
+
L.done({ kind: 'archive', id: r.id, archived: false });
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
render();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ── Archive ── */
|
|
257
|
+
export function openArchive(L, r) {
|
|
258
|
+
const F = { why: 'unrelated', note: '', board: '', boards: null, busy: false };
|
|
259
|
+
const { host, sh } = sheet('Archive · ' + r.id, 'clip');
|
|
260
|
+
const ta = textBox('Note', 'Anything you want to remember about it', '', (v) => { F.note = v; }, 'note');
|
|
261
|
+
L.ctx.api.get('/api/boards').then((res) => {
|
|
262
|
+
F.boards = res && res.ok ? (res.boards || []).filter((x) => x.ws === L.ctx.ws && x.id !== L.ctx.board.id) : [];
|
|
263
|
+
if (host.isConnected) render();
|
|
264
|
+
});
|
|
265
|
+
function render() {
|
|
266
|
+
const to = (F.boards || []).find((x) => x.id === F.board);
|
|
267
|
+
put(host, inote(el('b', null, r.title || r.id), ' · ' + (r.stage || 'no stage') + ' · ', statusChip(r.status)),
|
|
268
|
+
field('WHY', pills('why', AWHY, F.why, (v) => { F.why = v; render(); })),
|
|
269
|
+
F.why === 'dup' ? el('div', { class: 'fold w' }, icon('branch', 'sm'), ' A duplicate is better merged: its work moves to the one you keep. ',
|
|
270
|
+
lk({ 'data-il': 'merge-instead', onclick: () => openMerge(L, r, r.dupOf || null) }, 'Merge it instead')) : null,
|
|
271
|
+
F.why === 'unrelated' && F.boards && F.boards.length ? el('label', { class: 'chips2' }, el('span', { class: 'dim il-hint' }, 'Belongs on another board? Move it there instead'),
|
|
272
|
+
el('select', { 'data-il-board': '', 'aria-label': 'Move to board', onchange: (e) => { F.board = e.target.value; render(); } },
|
|
273
|
+
el('option', { value: '' }, 'Choose a board…'), ...F.boards.map((x) => el('option', { value: x.id, selected: x.id === F.board }, x.name))),
|
|
274
|
+
to ? pbtn('outline', 'Move to ' + to.name, 'next', { 'data-il-do': 'moveboard', onclick: () => moveBoard(to) }) : null) : null,
|
|
275
|
+
field('NOTE (OPTIONAL)', el('div', { class: 'form' }, ta)),
|
|
276
|
+
happens([['eye', 'It leaves every list, count and timeline of the board'],
|
|
277
|
+
['check', 'Nothing is deleted. The record, its work and its history stay in the repository'],
|
|
278
|
+
['undo', 'Restore it from the Archive any time, as it was'],
|
|
279
|
+
jiraLine(L)]),
|
|
280
|
+
foot(sh, 'Archive', go, 'archive', F.busy));
|
|
281
|
+
}
|
|
282
|
+
async function go() {
|
|
283
|
+
const why = (AWHY.find((w) => w[0] === F.why) || [null, ''])[1];
|
|
284
|
+
F.busy = true; render();
|
|
285
|
+
const res = await archive(L, r.id, true, why + (F.note.trim() ? ': ' + F.note.trim() : ''));
|
|
286
|
+
if (!res || !res.ok) { F.busy = false; if (host.isConnected) render(); failed('The archive', res); return; }
|
|
287
|
+
sh.close();
|
|
288
|
+
L.done({ kind: 'archive', id: r.id, archived: true });
|
|
289
|
+
toast(r.id + ' archived', async () => {
|
|
290
|
+
const u = await archive(L, r.id, false);
|
|
291
|
+
if (!u || !u.ok) { failed('The undo', u); return; }
|
|
292
|
+
L.done({ kind: 'archive', id: r.id, archived: false });
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async function moveBoard(to) {
|
|
296
|
+
const from = L.ctx.board;
|
|
297
|
+
const res = await L.ctx.api.post('/api/boards/assign', { id: r.id, to: to.id });
|
|
298
|
+
if (!res || !res.ok) { failed('The move', res); return; }
|
|
299
|
+
sh.close();
|
|
300
|
+
L.done({ kind: 'board', id: r.id });
|
|
301
|
+
toast(r.id + ' moved to ' + to.name, async () => {
|
|
302
|
+
const u = await L.ctx.api.post('/api/boards/assign', { id: r.id, to: from.id });
|
|
303
|
+
if (!u || !u.ok) { failed('The undo', u); return; }
|
|
304
|
+
L.done({ kind: 'board', id: r.id });
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
render();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* ── menus ── */
|
|
311
|
+
const mi = (ic, label, hint, attr, onPick) => ({ kids: [icon(ic, 'sm'), el('span', null, label, hint ? [el('br'), el('span', { class: 'dim il-hint' }, hint)] : null)], attrs: { [attr]: '' }, onPick });
|
|
312
|
+
function statusItems(L, r) {
|
|
313
|
+
if (r.archived) return [];
|
|
314
|
+
if (r.status === 'on hold') return [mi('play', 'Resume', 'Back to ' + r.stage + ', active again', 'data-il-resume', () => resume(L, r)), mi('x', 'Cancel…', 'Stop it for good, with a reason', 'data-il-cancel', () => openCancel(L, r))];
|
|
315
|
+
if (r.status === 'cancelled') return [mi('undo', 'Reopen', 'Back to ' + r.stage + ', active again', 'data-il-reopen', () => reopen(L, r))];
|
|
316
|
+
if (r.status === 'done') return [];
|
|
317
|
+
return [mi('moon', 'Put on hold…', 'Pause it; keeps its stage and work', 'data-il-hold', () => openHold(L, r)), mi('x', 'Cancel…', 'Stop it for good, with a reason', 'data-il-cancel', () => openCancel(L, r))];
|
|
318
|
+
}
|
|
319
|
+
function tidyItems(L, r) {
|
|
320
|
+
if (r.archived) return [mi('undo', r.archived.into ? 'Undo merge' : 'Restore', r.archived.into ? 'What moved goes back' : 'Back in the lists, as it was', 'data-il-restore', () => restore(L, r))];
|
|
321
|
+
return [mi('branch', 'Merge into another…', 'For a duplicate: its work moves over', 'data-il-merge', () => openMerge(L, r)), mi('clip', 'Archive…', 'Out of every list; restore any time', 'data-il-archive', () => openArchive(L, r))];
|
|
322
|
+
}
|
|
323
|
+
/** The row menu. stageItems: the Move to items (empty on a board that keeps the derived ladder, or for a cancelled initiative). */
|
|
324
|
+
export function rowMenu(anchor, L, r, onMove) {
|
|
325
|
+
const stages = !onMove || r.status === 'cancelled' || r.archived ? [] : wfOf(r, L.ctx.board).map((s) => ({ kids: [sw(s.cat), s.name, s.name === r.stage ? el('span', { class: 'dim' }, ' (here)') : null], current: s.name === r.stage, disabled: s.name === r.stage, attrs: { 'data-ini-moveto': s.name }, onPick: () => onMove(s.name) }));
|
|
326
|
+
const status = statusItems(L, r);
|
|
327
|
+
openMenu(anchor, { label: 'Actions for ' + r.id, items: [
|
|
328
|
+
...(stages.length ? [{ group: 'Move to a stage' }, ...stages] : []),
|
|
329
|
+
...(status.length ? [{ group: 'Status' }, ...status] : []),
|
|
330
|
+
{ group: 'Tidy up' }, ...tidyItems(L, r)] });
|
|
331
|
+
}
|
|
332
|
+
export function pageMenu(anchor, L, r) {
|
|
333
|
+
const status = statusItems(L, r);
|
|
334
|
+
openMenu(anchor, { label: 'Actions for ' + r.id, items: [...(status.length ? [{ group: 'Status' }, ...status] : []), { group: 'Tidy up' }, ...tidyItems(L, r)] });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* ── chips and the banner ── */
|
|
338
|
+
export function lifeChips(r) {
|
|
339
|
+
const s = r.stopped || {};
|
|
340
|
+
if (r.status === 'on hold') return [chip('since ' + (dayOf(s.at) || 'today')), s.back ? chip(icon('bell', 'sm'), 'check back ' + dayOf(s.back)) : null, s.reason ? el('span', { class: 'chip2 dim', title: 'Reason' }, trunc(s.reason, 48)) : null];
|
|
341
|
+
if (r.status === 'cancelled') return [s.at ? chip(dayOf(s.at)) : null, s.reason ? el('span', { class: 'chip2 dim', title: 'Reason' }, trunc(s.reason, 48)) : null];
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
export function archivedChips(r) {
|
|
345
|
+
const a = r.archived || {};
|
|
346
|
+
return [el('span', { class: 'chip2 mono' }, r.id),
|
|
347
|
+
a.into ? [el('span', { class: 'tag' }, icon('branch', 'sm'), 'Merged into ' + a.into), a.intoTitle && a.intoTitle !== a.into ? chip(trunc(a.intoTitle, 36)) : null]
|
|
348
|
+
: [el('span', { class: 'tag' }, icon('clip', 'sm'), 'Archived'), a.reason ? el('span', { class: 'chip2 dim' }, trunc(a.reason, 56)) : null],
|
|
349
|
+
a.at ? chip(dayOf(a.at)) : null, r.stage ? chip('was ' + r.stage) : null];
|
|
350
|
+
}
|
|
351
|
+
export const restoreBtn = (L, r) => pbtn('outline', r.archived && r.archived.into ? 'Undo merge' : 'Restore', 'undo', { 'data-il-restore': r.id, onclick: () => restore(L, r) });
|
|
352
|
+
export function dupChip(L, r) {
|
|
353
|
+
if (!r.dupOf || r.archived) return null;
|
|
354
|
+
return btn({ class: 'tag acc', 'data-il-merge': r.id, 'data-into': r.dupOf, title: 'The same anchor and most of the same words', onclick: () => openMerge(L, r, r.dupOf) },
|
|
355
|
+
icon('spark', 'sm'), 'Looks like a duplicate of ' + r.dupOf);
|
|
356
|
+
}
|
|
357
|
+
export const mergedFromChip = (r) => (r.mergedFrom && r.mergedFrom.length ? chip(icon('branch', 'sm'), 'Merged from ' + r.mergedFrom.join(', ')) : null);
|
|
358
|
+
export function lifeBanner(L, r) {
|
|
359
|
+
const s = r.stopped || {};
|
|
360
|
+
if (r.archived) {
|
|
361
|
+
const a = r.archived;
|
|
362
|
+
return el('div', { class: 'fold w il-banner', role: 'status', 'data-il': 'banner' }, icon('clip', 'sm'),
|
|
363
|
+
el('span', null, a.into ? 'Merged into ' + a.into + (a.intoTitle && a.intoTitle !== a.into ? ' ' + trunc(a.intoTitle, 40) : '') + ' on ' + dayOf(a.at) + '. This initiative is in the Archive.'
|
|
364
|
+
: 'Archived on ' + dayOf(a.at) + (a.reason ? ': ' + a.reason : '') + '. It is out of every list.'), restoreBtn(L, r));
|
|
365
|
+
}
|
|
366
|
+
if (r.status === 'on hold') {
|
|
367
|
+
return el('div', { class: 'fold w il-banner', role: 'status', 'data-il': 'banner' }, icon('moon', 'sm'),
|
|
368
|
+
el('span', null, 'On hold since ' + (dayOf(s.at) || 'today') + (s.reason ? ': ' + s.reason : '') + '.' + (s.back ? ' Check back ' + dayOf(s.back) + '.' : '') + ' Stage, work and history are kept.'),
|
|
369
|
+
el('span', { class: 'chips2' }, pbtn('outline', 'Resume', 'play', { 'data-il-resume': r.id, onclick: () => resume(L, r) }), lk({ 'data-il-cancel': r.id, onclick: () => openCancel(L, r) }, 'Cancel…')));
|
|
370
|
+
}
|
|
371
|
+
if (r.status === 'cancelled') {
|
|
372
|
+
return el('div', { class: 'fold il-banner', role: 'status', 'data-il': 'banner' }, icon('x', 'sm'),
|
|
373
|
+
el('span', null, 'Cancelled' + (s.at ? ' on ' + dayOf(s.at) : '') + (s.reason ? ': ' + s.reason : '') + '. Counted as cancelled, not done.'),
|
|
374
|
+
pbtn('outline', 'Reopen', 'undo', { 'data-il-reopen': r.id, onclick: () => reopen(L, r) }));
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
export const rowIcon = (r) => (r.archived ? (r.archived.into ? 'branch' : 'clip') : r.status === 'late' || r.status === 'at risk' ? 'alert' : r.status === 'done' ? 'check' : r.status === 'on hold' ? 'moon' : r.status === 'cancelled' ? 'x' : 'init');
|
|
379
|
+
export { closeMenu, plural, st };
|
|
@@ -133,7 +133,8 @@ export function ago(iso, today) {
|
|
|
133
133
|
export { MON };
|
|
134
134
|
|
|
135
135
|
/* ── chips ── */
|
|
136
|
-
export const statusChip = (s) => (s === 'at risk' ? st('warn', 'alert', 'At risk') : s === 'late' ? st('bad', 'clock', 'Late') : s === 'done' ? st('ok', 'check', 'Done')
|
|
136
|
+
export const statusChip = (s) => (s === 'at risk' ? st('warn', 'alert', 'At risk') : s === 'late' ? st('bad', 'clock', 'Late') : s === 'done' ? st('ok', 'check', 'Done')
|
|
137
|
+
: s === 'on hold' ? st('', 'moon', 'On hold') : s === 'cancelled' ? el('span', { class: 'tag' }, icon('x', 'sm'), 'Cancelled') : chip('On track'));
|
|
137
138
|
export const HEALTH = { ok: 'On track', warn: 'Needs attention', bad: 'Off track' };
|
|
138
139
|
export const healthSt = (h) => (h === 'ok' ? st('ok', 'check', 'On track') : h === 'warn' ? st('warn', 'alert', 'Needs attention') : h === 'bad' ? st('bad', 'alert', 'Off track') : chip('No health yet'));
|
|
139
140
|
export const ownerText = (o) => (o ? o : el('span', { class: 'dim' }, 'No owner'));
|
|
@@ -228,23 +229,27 @@ export function closeMenu() {
|
|
|
228
229
|
document.removeEventListener('pointerdown', m.off, true); document.removeEventListener('keydown', m.key, true);
|
|
229
230
|
m.node.remove(); m.anchor.setAttribute('aria-expanded', 'false');
|
|
230
231
|
}
|
|
231
|
-
|
|
232
|
+
/** items: [{ group } | { kids|label, onPick, disabled, current, checked, attrs }]. `checked` makes it a menuitemradio. `body` (a Node) is a
|
|
233
|
+
* panel drawn after the items, for a menu of controls such as the board's filter selects; it is a dialog then, not a menu. */
|
|
234
|
+
export function openMenu(anchor, { label, items, body }) {
|
|
232
235
|
if (menuOpen) { const was = menuOpen.anchor; closeMenu(); if (was === anchor) return; }
|
|
233
|
-
const node = el('div', { class: 'ini-menu', role: 'menu', 'aria-label': label, 'data-ini': 'menu' },
|
|
234
|
-
...items.map((it) => (it.group != null ? el('div', { class: 'ini-mlbl' }, it.group)
|
|
235
|
-
: btn({ role: 'menuitem', class: 'ini-mi', disabled: !!it.disabled, ...(it.current ? { 'aria-current': 'true' } : {}), ...(it.attrs || {}), onclick: () => { closeMenu(); it.onPick(); } }, ...[].concat(it.kids || it.label))))
|
|
236
|
+
const node = el('div', { class: 'ini-menu' + (body ? ' ini-mpanel' : ''), role: body ? 'dialog' : 'menu', 'aria-label': label, 'data-ini': 'menu' },
|
|
237
|
+
...(items || []).map((it) => (it.group != null ? el('div', { class: 'ini-mlbl' }, it.group)
|
|
238
|
+
: btn({ role: it.checked != null ? 'menuitemradio' : 'menuitem', class: 'ini-mi', disabled: !!it.disabled, ...(it.current ? { 'aria-current': 'true' } : {}), ...(it.checked != null ? { 'aria-checked': String(!!it.checked) } : {}), ...(it.attrs || {}), onclick: () => { closeMenu(); it.onPick(); } }, ...[].concat(it.kids || it.label)))),
|
|
239
|
+
body || null);
|
|
236
240
|
const host = anchor.closest('.c3pop') || anchor.closest('.c3') || document.body;
|
|
237
241
|
host.appendChild(node);
|
|
238
242
|
const r = anchor.getBoundingClientRect();
|
|
239
243
|
const w = Math.min(300, window.innerWidth - 16);
|
|
240
244
|
geo(node, { '--in-x': Math.max(8, Math.min(window.innerWidth - w - 8, r.right - w)) + 'px', '--in-y': Math.min(window.innerHeight - 60, r.bottom + 6) + 'px' });
|
|
241
|
-
const itemsOf = () => [...node.querySelectorAll('[role="menuitem"]:not(:disabled)')];
|
|
245
|
+
const itemsOf = () => [...node.querySelectorAll(body ? 'select,button:not(:disabled)' : '[role="menuitem"]:not(:disabled),[role="menuitemradio"]:not(:disabled)')];
|
|
242
246
|
const off = (e) => { if (!node.contains(e.target) && !anchor.contains(e.target)) closeMenu(); };
|
|
243
247
|
const key = (e) => {
|
|
244
248
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeMenu(); anchor.focus(); return; }
|
|
245
249
|
const l = itemsOf(); const at = l.indexOf(document.activeElement);
|
|
250
|
+
if (body && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) return; // a select in the panel takes the arrows
|
|
246
251
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const n = l.length; if (n) l[((at < 0 ? -1 : at) + (e.key === 'ArrowDown' ? 1 : -1) + n) % n].focus(); }
|
|
247
|
-
if (e.key === 'Tab') closeMenu();
|
|
252
|
+
if (e.key === 'Tab' && !body) closeMenu();
|
|
248
253
|
};
|
|
249
254
|
menuOpen = { node, anchor, off, key };
|
|
250
255
|
anchor.setAttribute('aria-expanded', 'true');
|
|
@@ -49,6 +49,7 @@ import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf
|
|
|
49
49
|
import { timeline, tlInit, tlRange } from './c3-ini-timeline.js';
|
|
50
50
|
import { RECORD_META, recordToolsFor } from './c3-ini-records.js';
|
|
51
51
|
import { agentSetting, loadAgentMode, MODES as AGENT_MODES } from './c3-driver.js';
|
|
52
|
+
import { lifeBanner, pageMenu, ensureLifeCss } from './c3-ini-life.js';
|
|
52
53
|
import { scanSetting, takeSettingsFor } from './c3-scan.js';
|
|
53
54
|
|
|
54
55
|
export { btn, geo };
|
|
@@ -104,6 +105,7 @@ function ensureCss() {
|
|
|
104
105
|
ensureC3Css();
|
|
105
106
|
ensureComponentsCss();
|
|
106
107
|
ensureIniCss(); // after components.css, so the reset in c3-initiatives.css comes after the primitive's own rules
|
|
108
|
+
ensureLifeCss();
|
|
107
109
|
}
|
|
108
110
|
const warnChip = (w) => st('warn', 'alert', w, { title: 'An order rule, shown as a warning. It never blocks.', 'data-ini-warning': w });
|
|
109
111
|
const buildTag = (b) => (b.build ? el('span', { class: 'tag' }, icon('jira', 'sm'), jiraLabel(b) ? 'Jira · ' + jiraLabel(b) : 'Jira not set') : el('span', { class: 'tag' }, 'PMOS tasks only'));
|
|
@@ -264,7 +266,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
264
266
|
if (!r.ok) continue;
|
|
265
267
|
const ctx = makeCtx(b, {});
|
|
266
268
|
const withWork = new Set(r.items.map((w) => w.ini));
|
|
267
|
-
const now = r.initiatives.filter((i) =>
|
|
269
|
+
const now = r.initiatives.filter((i) => ['on track', 'at risk', 'late'].includes(i.status) && i.start && i.end && (i.horizon === 'now' || withWork.has(i.id) || i.featured));
|
|
268
270
|
const pick = [...now.filter((i) => withWork.has(i.id) || i.featured), ...now.filter((i) => !withWork.has(i.id) && !i.featured && ['at risk', 'late'].includes(i.status)).sort((x, y) => String(x.end).localeCompare(String(y.end))).slice(0, 2)].slice(0, 6);
|
|
269
271
|
more += now.length - pick.length;
|
|
270
272
|
if (!pick.length) continue;
|
|
@@ -314,7 +316,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
314
316
|
const def = TEMPLATES[b.defTpl] || TEMPLATES[b.template] || TEMPLATES.product;
|
|
315
317
|
put(root,
|
|
316
318
|
pathCrumbs(b, null, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctx0.showTab('rep') }),
|
|
317
|
-
|
|
319
|
+
ib('Board settings', 'settings', { 'data-ini': 'board-settings', 'aria-haspopup': 'dialog', onclick: () => openBoardSettings(b) }))),
|
|
318
320
|
el('div', { class: 'calbody', 'data-ini-board-page': b.id },
|
|
319
321
|
lh('cols', b.name + (b.full ? ' · ' + b.full : ''), (HEALTH[b.health] || 'No health yet') + (risk ? ', ' + risk + ' at risk or late' : '') + (asks ? ', ' + plural(asks, 'new ask') + ' to triage' : '') + '. '
|
|
320
322
|
+ plural(c.total || 0, 'initiative') + (items.ok ? ', ' + plural(work.filter((w) => w.kind === 'Epic').length, 'epic') + ', ' + plural(work.filter((w) => w.kind !== 'Epic').length, 'item') : '') + '.'),
|
|
@@ -334,7 +336,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
334
336
|
const b = ctx.board;
|
|
335
337
|
const r = await loadItems(ctx);
|
|
336
338
|
if (!r.ok) { put(host, errLine('The board could not be read: ' + r.error + '.')); return; }
|
|
337
|
-
const inis = r.initiatives; const work = r.items; const open = inis.filter((i) => i.status
|
|
339
|
+
const inis = r.initiatives; const work = r.items; const open = inis.filter((i) => ['on track', 'at risk', 'late'].includes(i.status)); // on hold, done and cancelled are not open
|
|
338
340
|
const risk = inis.filter((i) => ['at risk', 'late'].includes(i.status)).sort((x, y) => String(x.end || '9').localeCompare(String(y.end || '9')));
|
|
339
341
|
const withWork = new Set(work.map((w) => w.ini));
|
|
340
342
|
/* up to six open initiatives: the ones with work, featured, or Now and not on track first, then the rest by end date */
|
|
@@ -722,9 +724,17 @@ export function mountInitiatives(pane, opts) {
|
|
|
722
724
|
const tabs = INI_TAB_LIST.filter(([k]) => k !== 'tickets' || bb.build).map(([k, l, ic]) => [k, l, ic,
|
|
723
725
|
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]);
|
|
724
726
|
const host = el('div', { class: 'calbody', 'data-ini-panel': tab });
|
|
727
|
+
// c3-initiative-lifecycle: a hold, cancel or resume is derived in a child process, so the page reads again until it shows it.
|
|
728
|
+
const ex = P.expect;
|
|
729
|
+
if (ex && ex.id === row.id && ex.kind === 'status' && (ex.status ? row.status !== ex.status : ['on hold', 'cancelled'].includes(row.status)) && (ex.tries = (ex.tries || 0) + 1) < 10) {
|
|
730
|
+
setTimeout(() => { if (token === P.seq && P.expect === ex) { P.ini = null; draw(); } }, 800);
|
|
731
|
+
} else if (ex && ex.id === row.id) P.expect = null;
|
|
732
|
+
const L = { ctx: ctxI, done: (change) => { P.expect = change || null; P.ini = null; P.boards = null; dropItems(); draw(); } };
|
|
725
733
|
put(root,
|
|
726
|
-
pathCrumbs(bb, row, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctxI.showTab('rep') })
|
|
734
|
+
pathCrumbs(bb, row, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctxI.showTab('rep') }),
|
|
735
|
+
ib('Actions for ' + row.id, 'more', { 'data-il-menu': row.id, 'aria-haspopup': 'menu', 'aria-expanded': 'false', title: 'Pause, cancel, merge or archive', onclick: (e) => pageMenu(e.currentTarget, L, row) }))),
|
|
727
736
|
el('div', { class: 'calbody', 'data-ini-page': row.id },
|
|
737
|
+
lifeBanner(L, row),
|
|
728
738
|
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(' · ')),
|
|
729
739
|
el('div', { class: 'chips2', 'data-ini': 'ini-chips' }, statusChip(row.status), chip(icon('bolt', 'sm'), plural(ep, 'epic') + ', ' + plural(work.length - ep, 'item')), buildTag(bb),
|
|
730
740
|
bb.build ? pill({ 'data-ini': 'jira-epics', 'aria-haspopup': 'dialog', title: 'The Jira epics of this initiative', onclick: () => openEpics(bb, row) }, icon('jira', 'sm'), (row.epics || []).length ? 'Jira epics ' + row.epics.join(', ') : 'Link Jira epics') : null,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
.c3 .c3ph-hd b{font-size:var(--fs-4);flex:1;min-width:0}
|
|
14
14
|
.c3 .c3ph-hdr{display:flex;align-items:center;gap:8px;flex:none}
|
|
15
15
|
.c3 .c3ph-dotn{display:inline-flex;align-items:center;gap:4px;font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
|
|
16
|
+
.c3 button.c3ph-dotn{height:auto;min-height:32px;padding:4px 8px;cursor:pointer}
|
|
16
17
|
.c3 .c3ph-body{flex:1;display:grid;gap:12px;align-content:start;padding:0 12px 16px}
|
|
17
18
|
.c3 .c3ph-card{display:grid;gap:8px;padding:14px}
|
|
18
19
|
.c3 .c3ph-card.lead{border-color:var(--acc)}
|
|
@@ -37,7 +38,8 @@
|
|
|
37
38
|
.c3 .c3ph-ask:focus-within{border-color:var(--acc)}
|
|
38
39
|
.c3 .c3ph-recdot{width:44px;height:44px;border:2px solid var(--destructive);display:grid;place-items:center;flex:none;padding:0}
|
|
39
40
|
.c3 .c3ph-reddot{width:18px;height:18px;border-radius:50%;background:var(--destructive)}
|
|
40
|
-
.c3 .c3ph-recbar{flex:1;display:flex;align-items:center;gap:
|
|
41
|
+
.c3 .c3ph-recbar{flex:1;display:flex;align-items:center;gap:8px;min-height:44px;min-width:0}
|
|
42
|
+
.c3 .c3ph-recbar>button{flex:none}
|
|
41
43
|
.c3 .c3ph-rt{flex:1;min-width:0;display:grid}
|
|
42
44
|
.c3 .c3ph-rt b{color:var(--destructive);font-size:var(--fs-3)}
|
|
43
45
|
.c3 .c3ph-rt .dim{font-size:var(--fs-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* It has no tabs and no inbox to visit. It brings the one thing that matters now (c3-phone-model.js momentOf), then the PM's own items one
|
|
5
5
|
* at a time, then what comes later today. Two controls sit at the bottom: "Tell PMOS" and the red Record button.
|
|
6
|
-
* moment card meeting under way: "Record this meeting?" (Record / Not this one), and
|
|
6
|
+
* moment card meeting under way: "Record this meeting?" (Record / Not this one), and Open focus
|
|
7
7
|
* recording (any recording): the live notes of GET /api/notes/one, read every 5 s: what was just heard, and the decisions,
|
|
8
8
|
* actions and questions picked out so far (dest-notes.js partsOf), as the desktop note shows them
|
|
9
9
|
* before a meeting: when it starts, who, what is prepared, the "before you go" items with Done
|
|
@@ -17,8 +17,12 @@
|
|
|
17
17
|
* Tell PMOS a line of text, filed by POST /api/capture { ws, text }: it lands in the Work inbox, where triage files it. The small mic
|
|
18
18
|
* points to the keyboard's own dictation; this page does not send audio anywhere.
|
|
19
19
|
* Record the red button records a conversation or a meeting, the same recorder as the desktop Record control (note-dock.js).
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* In demo mode it drives the simulated recorder and saves nothing.
|
|
21
|
+
* focus starting a recording opens focus (c3-focus.js, phone mode; mockup version 5): the desktop Meeting focus in one column. A meeting
|
|
22
|
+
* opens by its event; a recording that is not on the calendar opens by its note. "Back to your day" keeps recording; the bar
|
|
23
|
+
* at the bottom then shows the clock, Focus (back to it) and Stop.
|
|
24
|
+
* N waiting the count in the header opens the whole list (c3-phone-detail.js openAll).
|
|
25
|
+
* There is no way to the desktop pages on a phone.
|
|
22
26
|
* The moment is worked out again every minute; when it changes, a short banner says so. ?at=HH:MM sets the clock in demo mode only. */
|
|
23
27
|
import { el } from './dom.js';
|
|
24
28
|
import { button, iconButton } from './primitives.js';
|
|
@@ -83,7 +87,20 @@ export function mountPhone(root, ctx = {}) {
|
|
|
83
87
|
function startRec(m) {
|
|
84
88
|
if (!dock) { toast('The recorder is not ready yet.'); return; }
|
|
85
89
|
if (recState().mode !== 'idle') { toast('Stop the recording first.'); return; }
|
|
86
|
-
Promise.resolve(dock.start(m ? m.title : 'Conversation', m || null)).then(() => {
|
|
90
|
+
Promise.resolve(dock.start(m ? m.title : 'Conversation', m || null)).then(() => {
|
|
91
|
+
syncRec(); draw();
|
|
92
|
+
const st = recState(); if (st.mode === 'idle') return;
|
|
93
|
+
S.focusOf = m ? { id: st.id, event: m } : { id: st.id, note: st.id };
|
|
94
|
+
openLive();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/* focus for the recording under way: its meeting when it has one, else its note */
|
|
98
|
+
function openLive() {
|
|
99
|
+
const st = recState(); if (st.mode === 'idle') return;
|
|
100
|
+
const known = S.focusOf && S.focusOf.id === st.id ? S.focusOf : null;
|
|
101
|
+
const n = S.live && S.live.id === st.id ? S.live.note : null;
|
|
102
|
+
const ev = known ? known.event : n && n.meeting && n.meeting.kind === 'event' && n.meeting.event_id ? { id: n.meeting.event_id, start: n.meeting.start, end: n.meeting.end, title: n.meeting.title } : null;
|
|
103
|
+
if (ev) focus(ev); else focusNote(st.id, st.title);
|
|
87
104
|
}
|
|
88
105
|
function syncRec() {
|
|
89
106
|
const st = recState();
|
|
@@ -96,6 +113,7 @@ export function mountPhone(root, ctx = {}) {
|
|
|
96
113
|
foot.replaceChildren(el('div', { class: 'c3ph-recbar', 'data-c3ph-recbar': st.mode, role: 'status' },
|
|
97
114
|
el('span', { class: 'c3ph-dot' + (st.mode === 'paused' ? ' paused' : '') }),
|
|
98
115
|
el('div', { class: 'c3ph-rt' }, el('b', null, st.mode === 'paused' ? 'Paused' : 'Recording'), el('span', { class: 'dim' }, (st.title || 'Conversation') + ' · ', S.clock)),
|
|
116
|
+
button({ label: 'Focus', variant: 'primary', size: 'sm', icon: 'target', attrs: { 'data-c3ph-focus-bar': '' }, onClick: openLive }),
|
|
99
117
|
button({ label: 'Stop', variant: 'outline', size: 'sm', icon: 'square', attrs: { 'data-c3ph-stop': '' }, onClick: () => Promise.resolve(dock.stop()).then(() => { syncRec(); draw(); }) })));
|
|
100
118
|
}
|
|
101
119
|
}
|
|
@@ -195,7 +213,9 @@ export function mountPhone(root, ctx = {}) {
|
|
|
195
213
|
}
|
|
196
214
|
const decisions = () => waitRows().filter((r) => !r.pm).length;
|
|
197
215
|
const needOpts = { onDone: (r) => itemDone(r), onLater: (r) => itemLater(r, 'later-today') };
|
|
198
|
-
const focus = (m) => import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.id, start: m.start
|
|
216
|
+
const focus = (m) => import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.id, start: m.start, at: m.start, end: m.end || null, title: m.title }, date: String(m.start || '').slice(0, 10), phone: true }))
|
|
217
|
+
.catch(() => toast('Focus did not open.'));
|
|
218
|
+
const focusNote = (id, title) => import('./c3-focus.js').then((f) => f.openFocus({ note: id, event: { title: title || 'Conversation' }, phone: true })).catch(() => toast('Focus did not open.'));
|
|
199
219
|
/* a meeting's sheet (c3-phone-detail.js): Record it while the meeting has not ended, and the meeting companion */
|
|
200
220
|
function meetingSheet(m) {
|
|
201
221
|
const t = now().getTime(); const ends = Date.parse(m.end || m.start) || 0;
|
|
@@ -215,7 +235,7 @@ export function mountPhone(root, ctx = {}) {
|
|
|
215
235
|
p ? list('Decisions', p.decisions) : null, p ? list('Actions', p.actions) : null, p ? list('Open questions', p.questions) : null,
|
|
216
236
|
heard.length ? el('div', { class: 'c3ph-fit', 'data-c3ph-heard': String(heard.length) }, el('b', null, 'Just heard'), ...heard.map((x) => line((L.speakerOf ? L.speakerOf(n, x.source) + ': ' : '') + x.text)))
|
|
217
237
|
: line(!L ? 'Opening the notes.' : n ? 'Listening. What is said shows here, and PMOS picks out the decisions, actions and questions as they come.' : 'The notes of this recording could not be read. They are kept on your computer.'),
|
|
218
|
-
|
|
238
|
+
acts(btn('Open focus', 'primary', 'target', { 'data-c3ph-focus': ev ? ev.id : st.id }, openLive)));
|
|
219
239
|
}
|
|
220
240
|
|
|
221
241
|
function itemCard(r, hero) {
|
|
@@ -231,7 +251,7 @@ export function mountPhone(root, ctx = {}) {
|
|
|
231
251
|
function momentCard(mo) {
|
|
232
252
|
const m = mo.meeting;
|
|
233
253
|
if (mo.kind === 'meeting') {
|
|
234
|
-
const open = btn('Open
|
|
254
|
+
const open = btn('Open focus', 'outline', 'target', { 'data-c3ph-focus': m.id }, () => focus(m));
|
|
235
255
|
if (S.noRec.has(m.id)) return card(true, { 'data-c3ph-moment': 'meeting' }, why('Under way · ends ' + inText(mo.minutes)), big(m.title), line('Not recording. PMOS follows the agenda and what you mark.'), acts(open, btn('Record from now', 'ghost', 'mic', { 'data-c3ph-rec-meeting': m.id }, () => startRec(m))));
|
|
236
256
|
return card(true, { 'data-c3ph-moment': 'meeting' }, why('Starting now' + (who(m) ? ' · ' + who(m) : '')), big('Record ' + m.title + '?'),
|
|
237
257
|
el('div', { class: 'c3ph-rec-why' }, 'Recording lets PMOS follow the talk, bring up what you need at the right topic, and write the notes for you.'),
|
|
@@ -312,7 +332,7 @@ export function mountPhone(root, ctx = {}) {
|
|
|
312
332
|
const d = now();
|
|
313
333
|
const n = waitRows().length;
|
|
314
334
|
head.replaceChildren(el('b', null, WD[d.getDay()] + ' ' + d.getDate() + ' ' + MO[d.getMonth()]),
|
|
315
|
-
el('span', { class: 'c3ph-hdr' }, n ?
|
|
335
|
+
el('span', { class: 'c3ph-hdr' }, n ? button({ label: [c3ic('inbox', 'sm'), n + ' waiting'], variant: 'ghost', size: 'sm', attrs: { class: 'c3ph-dotn', 'data-c3ph-count': String(n), 'aria-label': n + ' waiting on you. See them all.' }, onClick: () => openAll(waitRows(), needOpts) }) : null));
|
|
316
336
|
}
|
|
317
337
|
|
|
318
338
|
const BANNER = { meeting: (m) => m.title + ' has started', before: (m, mo) => m.title + ' starts ' + inText(mo.minutes), after: (m) => m.title + ' just ended', gap: (m, mo) => (mo.minutes || 0) + ' free minutes', wrap: () => 'Time to wrap up', morning: () => 'Good morning' };
|