create-pmos 0.185.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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/scripts/workflow-state.js +2 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/c3-ib-triage-backlog-test.js +20 -13
  8. package/workspace/c3-ini-life-test.js +167 -0
  9. package/workspace/c3-initiatives-test.js +6 -6
  10. package/workspace/c3-phone-test.js +67 -16
  11. package/workspace/c3-quiet-test.js +2 -1
  12. package/workspace/c3-report-setup-test.js +1 -0
  13. package/workspace/guide/initiative-boards.md +37 -2
  14. package/workspace/guide/phone-companion.md +19 -4
  15. package/workspace/guide/reports-set-up.md +4 -2
  16. package/workspace/lib/engine/boards.js +73 -18
  17. package/workspace/lib/engine/workflow-state.js +2 -1
  18. package/workspace/lib/initiative-rows.js +2 -2
  19. package/workspace/lib/routes-boards.js +119 -6
  20. package/workspace/lib/routes-meeting-focus.js +19 -7
  21. package/workspace/lib/routes-report-setups.js +37 -4
  22. package/workspace/lib/vendor/scripts/workflow-state.js +2 -1
  23. package/workspace/package-lock.json +2 -2
  24. package/workspace/package.json +1 -1
  25. package/workspace/public/pm/c3-demo-boards.js +82 -17
  26. package/workspace/public/pm/c3-demo-focus.js +39 -1
  27. package/workspace/public/pm/c3-demo-rs.js +3 -1
  28. package/workspace/public/pm/c3-focus.css +53 -0
  29. package/workspace/public/pm/c3-focus.js +188 -44
  30. package/workspace/public/pm/c3-ib-initiatives.js +127 -60
  31. package/workspace/public/pm/c3-ib-items.js +2 -2
  32. package/workspace/public/pm/c3-ini-life.css +32 -0
  33. package/workspace/public/pm/c3-ini-life.js +379 -0
  34. package/workspace/public/pm/c3-ini-ui.js +12 -7
  35. package/workspace/public/pm/c3-initiatives.js +14 -4
  36. package/workspace/public/pm/c3-phone.css +3 -1
  37. package/workspace/public/pm/c3-phone.js +28 -8
  38. package/workspace/public/pm/c3-report-setup.js +8 -3
  39. package/workspace/public/pm/c3-report-store.js +14 -1
  40. package/workspace/server.js +7 -2
  41. 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') : chip('On track'));
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
- export function openMenu(anchor, { label, items }) {
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) => i.status !== 'done' && i.start && i.end && (i.horizon === 'now' || withWork.has(i.id) || i.featured));
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
- pbtn('outline', 'Board settings', 'settings', { 'data-ini': 'board-settings', 'aria-haspopup': 'dialog', onclick: () => openBoardSettings(b) }))),
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 !== 'done');
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:10px;min-height:44px}
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 the meeting companion (c3-focus.js, compact)
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
- * While it records, the bar shows the clock and Stop. In demo mode it drives the simulated recorder and saves nothing.
21
- * There is no way to the desktop pages on a phone. The meeting companion opens as the compact focus page with "Back to your day".
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(() => { syncRec(); draw(); });
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 }, pop: true, back: true })).catch(() => toast('The meeting companion did not open.'));
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
- ev ? acts(btn('Open the meeting companion', 'outline', 'target', { 'data-c3ph-focus': ev.id }, () => focus(ev))) : null);
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 the meeting companion', 'outline', 'target', { 'data-c3ph-focus': m.id }, () => focus(m));
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 ? el('span', { class: 'c3ph-dotn', 'data-c3ph-count': String(n) }, c3ic('inbox', 'sm'), n + ' waiting') : null));
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' };