create-pmos 0.162.0 → 0.164.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-sessions-panel-test.js +49 -0
- package/workspace/guide/console-calendar.md +17 -0
- package/workspace/guide/console3-sessions.md +12 -0
- package/workspace/lib/headless.js +1 -1
- package/workspace/lib/meeting-focus.js +292 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-meeting-focus.js +291 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +7 -1
- package/workspace/public/pm/c3-demo-focus.js +133 -0
- package/workspace/public/pm/c3-demo.js +3 -0
- package/workspace/public/pm/c3-focus.css +169 -0
- package/workspace/public/pm/c3-focus.js +587 -0
- package/workspace/public/pm/c3-session-term.js +69 -3
- package/workspace/public/pm/c3-sheet.js +5 -4
- package/workspace/public/pm/c3-shell.js +1 -0
- package/workspace/public/pm/components.css +1 -0
- package/workspace/public/pm/console3.css +16 -0
- package/workspace/public/pm/shell-recording.js +18 -2
- package/workspace/test-lists/c3-meeting-focus.json +6 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/* pm/c3-focus.js — the Meeting focus page of console3 (initiative c3-meeting-focus; the mockup the PM approved on 2026-10-12, version 7).
|
|
2
|
+
* openFocus({ event, date }) closeFocus()
|
|
3
|
+
* One calm full-screen page over console3, inside #c3-root: it covers the header, the tabs and the dock (they stay in the DOM, set inert) and Leave
|
|
4
|
+
* focus gives them back. Recording is not touched by leaving. It reads GET /api/meeting-focus every 3 seconds while it is open:
|
|
5
|
+
* header title, time, people, initiative; the clock (starts in, or elapsed and left); REC with Start recording or End meeting (the note dock);
|
|
6
|
+
* a thin bar of the stretches of talk, numbered by topic
|
|
7
|
+
* now line the topic the room is on, how long, change; buttons Topics, Captured and Note (side sheets)
|
|
8
|
+
* being said one sentence on the talk and up to four context chips
|
|
9
|
+
* stage the meeting's material as a card with Open (and Present for Slides); a Google Slides or Docs preview loads only on a press; before the start, what is prepared
|
|
10
|
+
* conversation the room's last two lines as a faint ticker; PMOS notes below it (only the newest is open); the ones PMOS holds; Ask PMOS quietly
|
|
11
|
+
* Nothing is invented: with the companion off, or before it has answered, the panel shows the ticker and a quiet line. Nothing is sent anywhere.
|
|
12
|
+
* In demo mode (?demo=1) c3-demo-focus.js answers the reads with the mockup's Partner API review. */
|
|
13
|
+
import { el } from './dom.js';
|
|
14
|
+
import { pmGet, pmPost } from './http.js';
|
|
15
|
+
import { ensureC3Css } from './pm-work-view.js';
|
|
16
|
+
import { ensureComponentsCss, button } from './primitives.js';
|
|
17
|
+
import { icon, sect, pbtn, lk, ib, put } from './c3-ini-ui.js';
|
|
18
|
+
import { openSheet, closeSheet } from './c3-sheet.js';
|
|
19
|
+
import { toast } from './c3-toast.js';
|
|
20
|
+
|
|
21
|
+
const POLL_MS = 3000;
|
|
22
|
+
const LAST_MINUTES = 5; // the last minutes of the meeting: the captured items show under the stage
|
|
23
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
24
|
+
const hhmm = (ms) => { const d = new Date(ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()); };
|
|
25
|
+
export const baseId = (id) => String(id || '').split('@')[0];
|
|
26
|
+
/** 3:07, or 1:03:07 from an hour on; a minus sign before a negative time. Pure. */
|
|
27
|
+
export function clockText(sec) {
|
|
28
|
+
const neg = sec < 0; const s = Math.abs(Math.round(sec));
|
|
29
|
+
const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const x = s % 60;
|
|
30
|
+
return (neg ? '-' : '') + (h ? h + ':' + pad(m) : String(m)) + ':' + pad(x);
|
|
31
|
+
}
|
|
32
|
+
/** What a link is, and how the page shows it: every link is a card; a Google Slides or Docs link also has a /preview address that the page loads only when the PM presses Show the preview here (no-network: the page reaches nothing by itself). Pure. */
|
|
33
|
+
export function classify(url) {
|
|
34
|
+
const raw = String(url || '').trim();
|
|
35
|
+
// A link pasted without its scheme (docs.google.com/…) is a web link.
|
|
36
|
+
let u; try { u = new URL(/^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : 'https://' + raw); } catch { return { kind: 'link', app: 'Link', url: String(url), host: '' }; }
|
|
37
|
+
const m = /^\/(presentation|document)\/d\/([A-Za-z0-9_-]+)/.exec(u.pathname);
|
|
38
|
+
if (u.protocol === 'https:' && u.hostname === 'docs.google.com' && m) {
|
|
39
|
+
const base = u.origin + '/' + m[1] + '/d/' + m[2];
|
|
40
|
+
return m[1] === 'presentation' ? { kind: 'slides', app: 'Google Slides', url: u.href, host: u.hostname, preview: base + '/preview', present: base + '/present' }
|
|
41
|
+
: { kind: 'docs', app: 'Google Docs', url: u.href, host: u.hostname, preview: base + '/preview' };
|
|
42
|
+
}
|
|
43
|
+
if (/\.atlassian\.net$/.test(u.hostname) && u.pathname.startsWith('/wiki')) return { kind: 'confluence', app: 'Confluence', url: u.href, host: u.hostname };
|
|
44
|
+
return { kind: 'link', app: u.hostname || 'Link', url: u.href, host: u.hostname };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let F = null; // the open page: { id, date, root, r (regions), sig, data, offset, ui, timers, sheet, ... }
|
|
48
|
+
|
|
49
|
+
function ensureCss() {
|
|
50
|
+
ensureC3Css(); ensureComponentsCss();
|
|
51
|
+
if (document.querySelector('link[data-c3-focus]')) return;
|
|
52
|
+
const l = document.createElement('link');
|
|
53
|
+
l.rel = 'stylesheet'; l.href = new URL('./c3-focus.css', import.meta.url).href; l.setAttribute('data-c3-focus', '');
|
|
54
|
+
document.head.appendChild(l);
|
|
55
|
+
}
|
|
56
|
+
const isDemo = () => !!document.querySelector('[data-c3-demo]');
|
|
57
|
+
const wsParam = () => { try { return new URLSearchParams(location.search).get('ws') || ''; } catch { return ''; } };
|
|
58
|
+
const nowMs = () => Date.now() + (F ? F.offset : 0);
|
|
59
|
+
const startMs = () => (F && F.data ? Date.parse(F.data.event.at) : NaN);
|
|
60
|
+
const endMs = () => (F && F.data && F.data.event.end ? Date.parse(F.data.event.end) : NaN);
|
|
61
|
+
|
|
62
|
+
/* ── open and close ───────────────────────────────────────────────────────────────────────────────────────────── */
|
|
63
|
+
|
|
64
|
+
export function closeFocus() {
|
|
65
|
+
if (!F) return;
|
|
66
|
+
const f = F; F = null;
|
|
67
|
+
for (const t of Object.values(f.timers)) { clearTimeout(t); clearInterval(t); }
|
|
68
|
+
for (const t of f.askTimers.values()) clearInterval(t);
|
|
69
|
+
if (f.sheetOpen) closeSheet();
|
|
70
|
+
f.root.remove();
|
|
71
|
+
const host = document.getElementById('c3-root');
|
|
72
|
+
if (host) host.classList.remove('fc-open');
|
|
73
|
+
const grid = host && host.querySelector('.c3grid'); if (grid) grid.inert = false;
|
|
74
|
+
try { const u = new URL(location.href); if (u.searchParams.has('focus')) { u.searchParams.delete('focus'); u.searchParams.delete('fdate'); history.replaceState(history.state, '', u); } } catch { /* no history */ }
|
|
75
|
+
if (f.opener && document.contains(f.opener) && typeof f.opener.focus === 'function') { try { f.opener.focus({ preventScroll: true }); } catch { /* not focusable any more */ } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function openFocus({ event, date } = {}) {
|
|
79
|
+
closeFocus();
|
|
80
|
+
const ev = event || {};
|
|
81
|
+
const id = String(ev.id || '');
|
|
82
|
+
const day = String(date || String(ev.at || ev.start || '').slice(0, 10) || '');
|
|
83
|
+
ensureCss();
|
|
84
|
+
const host = document.getElementById('c3-root') || document.body;
|
|
85
|
+
const root = el('div', { class: 'c3focus', role: 'region', 'aria-label': 'Meeting focus', 'data-c3focus': '' });
|
|
86
|
+
const r = {};
|
|
87
|
+
const page = el('div', { class: 'fc-page' },
|
|
88
|
+
r.top = el('header', { class: 'card fc-top', 'aria-label': 'Meeting' }),
|
|
89
|
+
r.now = el('div', { class: 'fc-nowl', 'data-fc': 'now-line' }),
|
|
90
|
+
r.band = el('div', { class: 'fc-bandwrap' }),
|
|
91
|
+
el('div', { class: 'fc-main' },
|
|
92
|
+
el('div', { class: 'fc-s' }, r.stage = el('div', { class: 'fc-stagewrap' }), r.cap = el('div', { class: 'fc-capwrap' })),
|
|
93
|
+
el('aside', { class: 'fc-w', 'aria-label': 'Conversation' },
|
|
94
|
+
el('section', { class: 'fc-cv', 'aria-label': 'The conversation, with PMOS' },
|
|
95
|
+
el('div', { class: 'fc-cv-h' }, el('b', null, 'Conversation'), el('span', { class: 'dim' }, 'PMOS replies only to you')),
|
|
96
|
+
r.tick = el('div', { class: 'fc-tk', 'aria-label': 'Last lines in the room', 'data-fc': 'ticker' }),
|
|
97
|
+
r.stream = el('ol', { class: 'fc-s2', role: 'list', 'data-fc': 'stream' }),
|
|
98
|
+
r.form = askForm()))));
|
|
99
|
+
root.append(page);
|
|
100
|
+
F = { id, date: day, ev0: ev, root, r, sig: {}, data: null, offset: 0, ui: { open: new Set(), folded: new Set(), pulled: [], stage: 0, picker: false, newTopic: false, addTopic: false, heldOpen: false, pending: new Map(), failed: new Map() },
|
|
101
|
+
timers: {}, askTimers: new Map(), sheet: null, sheetOpen: false, status: 'reading', error: '',
|
|
102
|
+
opener: document.activeElement && document.activeElement !== document.body ? document.activeElement : null, seenNotes: 0 };
|
|
103
|
+
// a fixed box inside an element with container-type is placed against that element, not the screen: lift it while the page is open
|
|
104
|
+
host.classList.add('fc-open');
|
|
105
|
+
host.append(root);
|
|
106
|
+
const grid = host.querySelector && host.querySelector('.c3grid'); if (grid) grid.inert = true;
|
|
107
|
+
try { const u = new URL(location.href); u.searchParams.set('focus', id); if (day) u.searchParams.set('fdate', day); history.replaceState(history.state, '', u); } catch { /* no history */ }
|
|
108
|
+
draw();
|
|
109
|
+
F.timers.clock = setInterval(tick, 1000);
|
|
110
|
+
load();
|
|
111
|
+
const lv = root.querySelector('[data-fc="leave"]'); if (lv) lv.focus({ preventScroll: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ── reading ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
115
|
+
|
|
116
|
+
async function load() {
|
|
117
|
+
const f = F; if (!f) return;
|
|
118
|
+
clearTimeout(f.timers.poll);
|
|
119
|
+
const e = f.ev0;
|
|
120
|
+
const p = new URLSearchParams();
|
|
121
|
+
if (f.id) p.set('event', f.id);
|
|
122
|
+
if (f.date) p.set('date', f.date);
|
|
123
|
+
if (!f.id && (e.at || e.start)) { p.set('at', e.at || e.start); if (e.title) p.set('title', e.title); }
|
|
124
|
+
const j = await pmGet('/api/meeting-focus?' + p.toString());
|
|
125
|
+
if (F !== f) return;
|
|
126
|
+
if (j && j.ok) {
|
|
127
|
+
f.data = j; f.status = 'ok'; f.error = '';
|
|
128
|
+
f.offset = Number.isFinite(j.now_ms) ? j.now_ms - Date.now() : 0;
|
|
129
|
+
refreshSheet();
|
|
130
|
+
} else if (j && j.status === 404) { f.status = 'missing'; f.error = (j && j.error) || 'no such meeting'; }
|
|
131
|
+
else { f.status = f.data ? 'ok' : 'error'; f.error = (j && j.error) || 'The meeting could not be read.'; f.stale = true; }
|
|
132
|
+
draw();
|
|
133
|
+
f.timers.poll = setTimeout(load, POLL_MS);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── drawing ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
137
|
+
|
|
138
|
+
/** Replace a region's children when its signature changed. */
|
|
139
|
+
function region(name, sig, build) {
|
|
140
|
+
const s = typeof sig === 'string' ? sig : JSON.stringify(sig);
|
|
141
|
+
if (F.sig[name] === s) return false;
|
|
142
|
+
F.sig[name] = s;
|
|
143
|
+
put(F.r[name], ...[].concat(build()));
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
const phaseOf = () => { const n = nowMs(); const s = startMs(); const e = endMs(); return Number.isFinite(s) && n < s ? 'before' : Number.isFinite(e) && n >= e ? 'after' : 'during'; };
|
|
147
|
+
const linksOf = (d) => ((d && d.event && d.event.links) || []).map((l) => ({ label: l.label, ...classify(l.url) }));
|
|
148
|
+
const topicNo = (d, id) => d.topics.findIndex((t) => t.id === id) + 1;
|
|
149
|
+
const currentOf = (d) => d.topics.find((t) => t.id === d.current) || null;
|
|
150
|
+
const openRunOf = (d) => (d.runs || []).filter((x) => x.to_min == null).pop() || null;
|
|
151
|
+
const capCount = (d) => (d.captured ? d.captured.decisions.length + d.captured.actions.length + d.captured.questions.length : 0);
|
|
152
|
+
const recState = (d) => (d.note ? d.note.state : 'none');
|
|
153
|
+
|
|
154
|
+
function draw() {
|
|
155
|
+
if (!F) return;
|
|
156
|
+
const d = F.data;
|
|
157
|
+
if (!d) {
|
|
158
|
+
const title = F.ev0.title || 'Meeting';
|
|
159
|
+
region('top', ['wait', F.status, F.error], () => [el('div', { class: 'fc-id' }, leaveBtn(), el('div', { class: 'fc-tt' }, el('h1', null, title),
|
|
160
|
+
el('div', { class: 'dim mono fc-sub', role: 'status' }, F.status === 'missing' ? 'PMOS has no record of this meeting on the calendar, so there is nothing to show.' : F.status === 'error' ? F.error : 'Reading the meeting.')))]);
|
|
161
|
+
for (const k of ['now', 'band', 'stage', 'cap', 'tick', 'stream']) region(k, 'wait', () => []);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const ph = phaseOf();
|
|
165
|
+
drawTop(d, ph); drawNow(d, ph); drawBand(d); drawStage(d, ph); drawCap(d, ph); drawConversation(d);
|
|
166
|
+
tick();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const leaveBtn = () => pbtn('ghost', 'Leave focus', 'prev', { 'data-fc': 'leave', title: 'Back to where you were. The meeting keeps recording.' });
|
|
170
|
+
|
|
171
|
+
function leftText(d, ph) {
|
|
172
|
+
if (ph !== 'during') return { txt: '', warn: false };
|
|
173
|
+
const left = Math.max(0, Math.round((endMs() - nowMs()) / 60000));
|
|
174
|
+
const nc = d.topics.filter((t) => t.state === 'notyet').length;
|
|
175
|
+
if (!d.current || !nc) return { txt: '', warn: false };
|
|
176
|
+
const warn = left < nc * 6;
|
|
177
|
+
return { txt: warn ? nc + ' topic' + (nc > 1 ? 's' : '') + ' left for ' + left + ' min' : '', warn };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function drawTop(d, ph) {
|
|
181
|
+
const e = d.event; const rec = recState(d); const ini = (d.brief && d.brief.initiative) || null; const lt = leftText(d, ph);
|
|
182
|
+
const sig = [e.title, e.at, e.end, e.attendees, ini, e.initiative, ph, rec, lt.txt, d.topics.map((t) => t.id), d.runs, F.status === 'ok' && F.stale ? 1 : 0];
|
|
183
|
+
region('top', sig, () => {
|
|
184
|
+
const s = startMs(); const f = endMs();
|
|
185
|
+
const people = (e.attendees || []);
|
|
186
|
+
const iniText = ini ? ini.title : e.initiative;
|
|
187
|
+
const when = Number.isFinite(s) ? hhmm(s) + (Number.isFinite(f) ? ' to ' + hhmm(f) : '') : '';
|
|
188
|
+
const clock = ph === 'before'
|
|
189
|
+
? [el('div', null, el('b', { class: 'mono', 'data-fc': 'big' }, clockText((s - nowMs()) / 1000)), el('span', { class: 'dim mono' }, 'starts in'))]
|
|
190
|
+
: [el('div', null, el('b', { class: 'mono', 'data-fc': 'big' }, clockText((nowMs() - s) / 1000)), el('span', { class: 'dim mono' }, 'elapsed')),
|
|
191
|
+
Number.isFinite(f) ? el('div', null, el('b', { class: 'mono', 'data-fc': 'left' }, clockText((f - nowMs()) / 1000)), el('span', { class: 'dim mono' }, 'left')) : null];
|
|
192
|
+
const recLabel = rec === 'recording' ? 'REC' : rec === 'paused' ? 'Paused' : rec === 'ended' ? 'Ended' : '';
|
|
193
|
+
const track = d.runs.map((r) => {
|
|
194
|
+
const total = Number.isFinite(s) && Number.isFinite(f) ? (f - s) / 60000 : 60;
|
|
195
|
+
const open = r.to_min == null;
|
|
196
|
+
const to = open ? Math.max(r.from_min, (nowMs() - s) / 60000) : r.to_min;
|
|
197
|
+
const w = Math.max(0, (to - r.from_min) / total * 100);
|
|
198
|
+
const t = d.topics.find((x) => x.id === r.topic);
|
|
199
|
+
const seg = el('i', { class: open ? 'c' : 'd', ...(open ? { 'data-fc': 'currun' } : {}), title: (t ? t.text : '') + ' · ' + Math.round(to - r.from_min) + ' min' }, w >= 6 ? String(topicNo(d, r.topic)) : '');
|
|
200
|
+
seg.style.setProperty('--fc-left', Math.min(100, r.from_min / total * 100) + '%');
|
|
201
|
+
seg.style.setProperty('--fc-width', Math.min(100, w) + '%');
|
|
202
|
+
return seg;
|
|
203
|
+
});
|
|
204
|
+
return [
|
|
205
|
+
el('div', { class: 'fc-id' }, leaveBtn(),
|
|
206
|
+
el('div', { class: 'fc-tt' }, el('h1', null, e.title),
|
|
207
|
+
el('div', { class: 'chips2 dim mono' }, when ? el('span', null, when) : null, iniText ? el('span', { class: 'chip2' }, icon('init', 'sm'), iniText) : null,
|
|
208
|
+
people.length ? el('span', { title: people.join(', '), 'data-fc': 'people' }, people.length + (people.length === 1 ? ' person' : ' people')) : null))),
|
|
209
|
+
el('div', { class: 'fc-clock', role: 'timer', 'aria-live': 'off' }, ...clock, el('div', { class: 'fc-end' }, el('span', { class: 'tag' + (lt.warn ? ' fc-warn' : ''), 'data-fc': 'proj', hidden: !lt.txt }, lt.txt))),
|
|
210
|
+
el('div', { class: 'fc-acts' },
|
|
211
|
+
recLabel ? el('span', { class: 'dim mono fc-recd', 'data-fc': 'rec', 'data-rec': rec, title: rec === 'recording' ? 'Recording this meeting' : '' }, rec === 'recording' ? el('span', { class: 'fc-dot' }) : null, recLabel) : null,
|
|
212
|
+
rec === 'recording' || rec === 'paused' ? pbtn('outline', 'End meeting', 'stop', { 'data-fc': 'end', title: 'Stops the recording and saves the note' })
|
|
213
|
+
: rec !== 'ended' && ph !== 'after' ? pbtn('primary', 'Start recording', 'play', { 'data-fc': 'start', title: 'Records this meeting in a note' }) : null),
|
|
214
|
+
el('div', { class: 'fc-track', role: 'img', 'aria-label': 'What the room talked about, in order. Numbers match the topic list.' }, el('div', { class: 'fc-runs' }, ...track), el('span', { class: 'fc-nowm', 'data-fc': 'nowm' })),
|
|
215
|
+
F.stale ? el('p', { class: 'dim mono fc-stale', role: 'status' }, 'The last read failed. Showing what PMOS had.') : null,
|
|
216
|
+
];
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function drawNow(d, ph) {
|
|
221
|
+
const cur = currentOf(d); const nc = d.topics.filter((t) => t.state === 'notyet').length; const cap = capCount(d);
|
|
222
|
+
region('now', [cur && cur.id, cur && cur.text, d.topics.length, nc, cap, d.note && d.note.id, ph, d.topics[0] && d.topics[0].text], () => {
|
|
223
|
+
const left = cur
|
|
224
|
+
? el('span', { class: 'fc-nowt' }, el('span', { class: 'ki hot' }, icon('play', 'sm')), el('b', { 'data-fc': 'topic' }, cur.text), el('span', { class: 'mono dim', 'data-fc': 'onfor' }, ''),
|
|
225
|
+
lk({ 'data-fc': 'topics', title: 'Not this topic? Change it here' }, 'change'))
|
|
226
|
+
: el('span', { class: 'fc-nowt dim', 'data-fc': 'first' }, d.topics.length ? [ph === 'before' ? 'First up: ' : 'No topic yet. First up: ', el('b', null, d.topics[0].text)] : 'No topics yet. PMOS adds topics as they come up.');
|
|
227
|
+
return [left, el('span', { class: 'fc-grow' }),
|
|
228
|
+
lk({ 'data-fc': 'topics' }, icon('todo', 'sm'), d.topics.length + ' topic' + (d.topics.length === 1 ? '' : 's') + (cur && nc ? ' · ' + nc + ' not covered' : '')),
|
|
229
|
+
ph !== 'before' || d.note ? lk({ 'data-fc': 'captured' }, icon('check', 'sm'), 'Captured ' + cap) : null,
|
|
230
|
+
d.note ? lk({ 'data-fc': 'note' }, icon('notes', 'sm'), 'Note') : null];
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ctxChip(c) {
|
|
235
|
+
return button({ variant: 'ghost', label: [el('b', null, c.label), c.sub ? el('span', null, c.sub) : null], attrs: { class: 'fc-cx', 'data-fc': 'chip', title: c.sub || c.label } });
|
|
236
|
+
}
|
|
237
|
+
function drawBand(d) {
|
|
238
|
+
region('band', [d.gist, d.chips], () => {
|
|
239
|
+
if (!d.gist && !(d.chips || []).length) return [];
|
|
240
|
+
F.chips = d.chips || [];
|
|
241
|
+
return [el('div', { class: 'fc-gist', 'aria-live': 'polite', 'data-fc': 'gist' },
|
|
242
|
+
d.gist ? el('p', null, icon('spark', 'sm'), el('span', null, el('span', { class: 'dim mono fc-gl' }, "What's being said"), ' ', d.gist)) : null,
|
|
243
|
+
(d.chips || []).length ? el('div', { class: 'fc-cxs', role: 'list', 'aria-label': 'Talked about, with context' }, ...d.chips.map((c, i) => { const b = ctxChip(c); b.dataset.i = String(i); return b; })) : null)];
|
|
244
|
+
});
|
|
245
|
+
F.chips = d.chips || [];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ── the stage ────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
249
|
+
|
|
250
|
+
function linkRow(l) {
|
|
251
|
+
return el('li', { class: 'nrow', 'data-fc-link': l.kind },
|
|
252
|
+
el('span', { class: 'ki' }, icon(l.kind === 'confluence' || l.kind === 'link' ? 'out' : 'doc')),
|
|
253
|
+
el('div', { class: 'nb' }, el('span', { class: 'tt' }, l.label), el('div', { class: 'chips' }, el('span', { class: 'chip2' }, icon('out', 'sm'), l.app))),
|
|
254
|
+
el('div', { class: 'acts' }, openBtn(l), l.present ? presentBtn(l) : null));
|
|
255
|
+
}
|
|
256
|
+
const openBtn = (l) => pbtn('outline', l.kind === 'link' ? 'Open' : 'Open in ' + l.app, null, { 'data-fc': 'open-link', 'data-url': l.url, title: 'Opens it in a new tab' });
|
|
257
|
+
const presentBtn = (l) => pbtn('ghost', 'Present in ' + l.app, 'play', { 'data-fc': 'present', 'data-url': l.present, title: 'Opens the deck in present mode in a new tab. Share that tab in the call.' });
|
|
258
|
+
|
|
259
|
+
function beforeList(d, links) {
|
|
260
|
+
const b = d.brief || {};
|
|
261
|
+
const rows = [];
|
|
262
|
+
for (const l of links) rows.push(linkRow(l));
|
|
263
|
+
if (b.prep) rows.push(el('li', { class: 'nrow' }, el('span', { class: 'ki' }, icon('alert')), el('div', { class: 'nb' }, el('span', { class: 'tt' }, b.prep), el('div', { class: 'chips' }, el('span', { class: 'tag' }, 'To be ready'))), el('span')));
|
|
264
|
+
for (const m of (b.matters || []).slice(0, 3)) rows.push(el('li', { class: 'nrow' }, el('span', { class: 'ki' }, icon('scale')), el('div', { class: 'nb' }, el('span', { class: 'tt' }, m.text),
|
|
265
|
+
el('div', { class: 'chips' }, el('span', { class: 'tag' + (m.tone === 'bad' ? ' fc-bad' : m.tone === 'warn' ? ' fc-warn' : '') }, m.tag), m.meta ? el('span', { class: 'tag' }, m.meta) : null)), el('span')));
|
|
266
|
+
if (b.last && b.last.actions && b.last.actions.length) for (const a of b.last.actions.slice(0, 3)) rows.push(el('li', { class: 'nrow' }, el('span', { class: 'ki' }, icon('notes')), el('div', { class: 'nb' }, el('span', { class: 'tt' }, a.text),
|
|
267
|
+
el('div', { class: 'chips' }, el('span', { class: 'tag' }, 'From last time'), a.owner ? el('span', { class: 'tag' }, a.owner) : null, a.due ? el('span', { class: 'tag' }, 'due ' + a.due) : null)), el('span')));
|
|
268
|
+
return rows;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function drawStage(d, ph) {
|
|
272
|
+
const links = linksOf(d);
|
|
273
|
+
const cur = currentOf(d);
|
|
274
|
+
const embeddable = links.filter((l) => l.preview);
|
|
275
|
+
const pick = links[F.ui.stage] || embeddable[0] || links[0] || null;
|
|
276
|
+
const sig = [ph, links, F.ui.stage, F.ui.preview, cur && cur.text, d.brief && [d.brief.prep, d.brief.matters, d.brief.last]];
|
|
277
|
+
region('stage', sig, () => {
|
|
278
|
+
if (ph === 'before') {
|
|
279
|
+
const rows = beforeList(d, links);
|
|
280
|
+
return [sect({ ic: 'doc', title: 'Before it starts', attrs: { class: 'fc-stage', 'data-fc': 'stage', 'data-fc-stage': 'before' } },
|
|
281
|
+
rows.length ? el('ul', { class: 'list', role: 'list' }, ...rows) : el('div', { class: 'fold' }, icon('moon', 'sm'), 'Nothing is prepared for this meeting yet. Nothing is linked in the invite.'))];
|
|
282
|
+
}
|
|
283
|
+
if (!pick) return [sect({ ic: 'doc', title: 'Material', attrs: { class: 'fc-stage', 'data-fc': 'stage', 'data-fc-stage': 'none' } }, el('div', { class: 'fold' }, icon('moon', 'sm'), 'No material is linked to this meeting.'))];
|
|
284
|
+
const right = links.length > 1 ? el('div', { class: 'seg', role: 'group', 'aria-label': 'Material', 'data-fc': 'material' },
|
|
285
|
+
...links.map((l, i) => button({ variant: 'ghost', label: [icon(l.preview ? 'doc' : 'out', 'sm'), l.app], attrs: { 'aria-pressed': String(l === pick), 'data-fc': 'material-pick', 'data-i': String(i), title: l.label } }))) : null;
|
|
286
|
+
const body = pick.preview && F.ui.preview === pick.url
|
|
287
|
+
? [el('div', { class: 'fc-frame', 'data-fc': 'preview-wrap' }, el('iframe', { src: pick.preview, title: 'Preview of ' + pick.label + ' in ' + pick.app, loading: 'lazy', referrerpolicy: 'no-referrer', allow: 'fullscreen',
|
|
288
|
+
sandbox: 'allow-scripts allow-same-origin allow-popups allow-forms', 'data-fc': 'preview', 'data-app': pick.kind })),
|
|
289
|
+
el('div', { class: 'fc-strip' }, el('span', { class: 'dim mono fc-src' }, icon('lock', 'sm'), 'Shown by ' + pick.app + ' itself. PMOS shows nothing to the room.'), el('span', { class: 'fc-grow' }), openBtn(pick), pick.present ? presentBtn(pick) : null)]
|
|
290
|
+
: [el('div', { class: 'fc-card', 'data-fc': 'link-card' }, el('span', { class: 'ki' }, icon(pick.preview ? 'doc' : 'out')), el('div', { class: 'nb' }, el('b', null, pick.label), el('span', { class: 'dim mono' }, pick.app + (pick.host && pick.host !== pick.app ? ' · ' + pick.host : ''))), openBtn(pick), pick.present ? presentBtn(pick) : null),
|
|
291
|
+
pick.preview ? el('div', { class: 'fc-strip' }, el('span', { class: 'dim mono fc-src' }, icon('lock', 'sm'), 'PMOS loads nothing from ' + pick.app + ' until you ask.'), el('span', { class: 'fc-grow' }),
|
|
292
|
+
button({ variant: 'outline', label: [icon('eye', 'sm'), 'Show the preview here'], attrs: { 'data-fc': 'preview-on', title: 'Load the ' + pick.app + ' preview into this page' } })) : null];
|
|
293
|
+
return [sect({ ic: 'doc', title: cur ? 'Material · ' + cur.text : 'Material', right, attrs: { class: 'fc-stage', 'data-fc': 'stage', 'data-fc-stage': pick.preview && F.ui.preview === pick.url ? pick.kind : 'card' } }, ...body)];
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ── captured, under the stage in the last minutes ─────────────────────────────────────────────────────────────── */
|
|
298
|
+
|
|
299
|
+
function capturedBody(d) {
|
|
300
|
+
const c = d.captured || { decisions: [], actions: [], questions: [] };
|
|
301
|
+
const col = (h, ic, items, render) => el('div', { class: 'fc-col' }, el('div', { class: 'dim mono fc-lbl' }, icon(ic, 'sm'), ' ' + h + ' ', el('span', { class: 'bdg g' }, String(items.length))),
|
|
302
|
+
items.length ? el('ul', { class: 'list', role: 'list' }, ...items.map(render)) : el('div', { class: 'fold' }, 'None yet'));
|
|
303
|
+
const row = (ic, text, chips) => el('li', { class: 'nrow' }, el('span', { class: 'ki' }, icon(ic)), el('div', { class: 'nb' }, el('span', { class: 'tt' }, text), el('div', { class: 'chips' }, ...chips)), el('span'));
|
|
304
|
+
return [el('div', { class: 'fc-capg', 'data-fc': 'captured-list' },
|
|
305
|
+
col('Decisions', 'scale', c.decisions, (x) => row('scale', x.text, [el('span', { class: 'tag' }, 'draft')])),
|
|
306
|
+
col('Actions', 'todo', c.actions, (x) => row('todo', x.text, [x.owner ? el('span', { class: 'tag' }, x.owner) : el('span', { class: 'tag fc-bad' }, 'no owner yet'), x.due ? el('span', { class: 'tag' }, 'due ' + x.due) : null, el('span', { class: 'tag' }, 'draft')])),
|
|
307
|
+
col('Open questions', 'reply', c.questions, (x) => row('reply', x.text, []))),
|
|
308
|
+
el('p', { class: 'dim fc-note' }, 'Drafts from the live note. After the meeting you confirm them once. Nothing is sent to anyone.')];
|
|
309
|
+
}
|
|
310
|
+
function drawCap(d, ph) {
|
|
311
|
+
const left = (endMs() - nowMs()) / 60000;
|
|
312
|
+
const show = (ph === 'during' && left <= LAST_MINUTES) || ph === 'after';
|
|
313
|
+
region('cap', [show, d.captured], () => (show ? [sect({ ic: 'check', title: 'Captured so far', attrs: { class: 'fc-captured', 'data-fc': 'captured-inline' } }, ...capturedBody(d))] : []));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ── the conversation ─────────────────────────────────────────────────────────────────────────────────────────── */
|
|
317
|
+
|
|
318
|
+
function actBtn(a) {
|
|
319
|
+
return pbtn('outline', a.label, a.url ? 'out' : null, { 'data-fc': 'act', 'data-i': 'x', onclick: () => (a.url ? openUrl(a.url) : openRef(a.ref)) });
|
|
320
|
+
}
|
|
321
|
+
function noteOpen(n, d, i, newest) {
|
|
322
|
+
const heard = n.heard ? el('span', { class: 'fc-re' }, n.heard) : null;
|
|
323
|
+
const why = n.pulledWhy || n.why;
|
|
324
|
+
return el('li', { class: 'fc-a' + (newest ? ' new' : ''), 'data-fc': 'note-card', 'data-cat': n.cat, 'data-open': 'true', 'aria-live': 'polite' },
|
|
325
|
+
el('span', { class: 'fc-who' }, el('span', { class: 'fc-avdot', 'aria-hidden': 'true' }, icon('spark', 'sm')), el('b', { class: 'mono' }, 'PMOS'), el('span', { class: 'fc-cat' }, n.cat), el('span', { class: 'dim mono fc-t' }, hhmm(n.at))),
|
|
326
|
+
heard, el('p', { class: 'fc-b' }, n.body),
|
|
327
|
+
(n.acts || []).length ? el('div', { class: 'fc-acts2' }, ...n.acts.map(actBtn)) : null,
|
|
328
|
+
el('span', { class: 'fc-meta dim' }, why ? el('span', null, el('b', null, 'Why now'), ' ' + why) : null, n.src ? el('span', null, 'from ' + n.src) : null),
|
|
329
|
+
newest ? button({ variant: 'ghost', label: icon('x', 'sm'), attrs: { class: 'fc-x', 'data-fc': 'fold', 'data-id': n.id, 'aria-label': 'Fold it', title: 'Fold it' } }) : null);
|
|
330
|
+
}
|
|
331
|
+
function noteShut(n) {
|
|
332
|
+
return el('li', { class: 'fc-a shut', 'data-fc': 'note-card', 'data-cat': n.cat, 'data-open': 'false' },
|
|
333
|
+
button({ variant: 'ghost', label: el('span', { class: 'fc-1' }, el('span', { class: 'fc-avdot', 'aria-hidden': 'true' }, icon('spark', 'sm')), el('span', { class: 'tag' }, n.cat), el('span', { class: 'fc-1t' }, n.body)), attrs: { class: 'fc-sb', 'data-fc': 'unfold', 'data-id': n.id, title: 'Open' } }));
|
|
334
|
+
}
|
|
335
|
+
function askBubble(a, err) {
|
|
336
|
+
return el('li', { class: 'fc-r me priv', 'data-fc': 'ask-q' }, el('span', { class: 'fc-who mono' }, 'You, to PMOS only'), el('p', null, a.question),
|
|
337
|
+
a.state === 'working' ? el('span', { class: 'dim mono fc-wait', role: 'status' }, 'PMOS is reading the record.') : null,
|
|
338
|
+
err ? el('span', { class: 'fc-err', role: 'status' }, 'PMOS could not answer: ' + err) : null);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function drawConversation(d) {
|
|
342
|
+
const tail = (d.tail || []).slice(-2);
|
|
343
|
+
region('tick', tail, () => tail.map((x) => el('p', null, el('span', { class: 'mono' }, x.who), ' ' + x.text)));
|
|
344
|
+
const notes = d.notes || [];
|
|
345
|
+
const pulled = F.ui.pulled.map((id) => notes.find((n) => n.id === id)).filter(Boolean);
|
|
346
|
+
const held = notes.filter((n) => n.show === 'hold' && !F.ui.pulled.includes(n.id));
|
|
347
|
+
const shown = notes.filter((n) => n.show !== 'hold').concat(pulled.map((n) => ({ ...n, at: F.pulledAt && F.pulledAt[n.id] || n.at, pulledWhy: 'You asked for it early. PMOS was holding it ' + (n.when || 'for later') + '.' })));
|
|
348
|
+
const asks = (d.asks || []).slice();
|
|
349
|
+
for (const [id, p] of F.ui.pending) if (!asks.some((a) => a.id === id)) asks.push({ id, at: p.at, question: p.question, state: 'working' });
|
|
350
|
+
const items = [...shown.map((n) => ({ kind: 'note', at: n.at, n })), ...asks.map((a) => ({ kind: 'ask', at: a.at, a }))]
|
|
351
|
+
.map((x, i) => ({ ...x, i })).sort((a, b) => a.at - b.at || (a.kind === 'ask' ? -1 : 1) - (b.kind === 'ask' ? -1 : 1) || a.i - b.i);
|
|
352
|
+
let newestAt = -1; items.forEach((x, k) => { if (x.kind === 'note') newestAt = k; });
|
|
353
|
+
const comp = d.companion || {};
|
|
354
|
+
const recording = recState(d) === 'recording';
|
|
355
|
+
const listen = !recording ? 'PMOS listens when the meeting records.'
|
|
356
|
+
: comp.on ? 'PMOS is listening.' : 'PMOS is listening to the transcript only. The companion is off, so it adds no notes.';
|
|
357
|
+
const sig = [items.map((x) => x.kind === 'note' ? [x.n.id, x.n.show, x.n.pulledWhy ? 1 : 0] : [x.a.id, x.a.state]), newestAt, [...F.ui.open], [...F.ui.folded], held.map((n) => n.id), F.ui.heldOpen, listen, comp.error || '', [...F.ui.failed], recording];
|
|
358
|
+
const changed = region('stream', sig, () => {
|
|
359
|
+
const rows = items.map((x, k) => {
|
|
360
|
+
if (x.kind === 'ask') return askBubble(x.a, F.ui.failed.get(x.a.id));
|
|
361
|
+
const isNew = k === newestAt;
|
|
362
|
+
const open = isNew ? !F.ui.folded.has(x.n.id) : F.ui.open.has(x.n.id);
|
|
363
|
+
return open ? noteOpen(x.n, d, k, isNew) : noteShut(x.n);
|
|
364
|
+
});
|
|
365
|
+
if (held.length) {
|
|
366
|
+
const det = el('details', { class: 'fc-held', 'data-fc': 'held', ...(F.ui.heldOpen ? { open: true } : {}) },
|
|
367
|
+
el('summary', null, el('span', { class: 'fc-avdot', 'aria-hidden': 'true' }, icon('spark', 'sm')), 'PMOS is holding ' + held.length + ' for the right moment'),
|
|
368
|
+
el('ul', { role: 'list' }, ...held.map((n) => el('li', null, button({ variant: 'ghost', label: [el('span', { class: 'tag' }, n.cat), el('span', { class: 'fc-hw dim' }, n.when || '')], attrs: { class: 'fc-pl', 'data-fc': 'pull', 'data-id': n.id, title: 'Show it now' } })))));
|
|
369
|
+
det.addEventListener('toggle', () => { if (F) F.ui.heldOpen = det.open; });
|
|
370
|
+
rows.push(el('li', { class: 'fc-heldli' }, det));
|
|
371
|
+
}
|
|
372
|
+
rows.push(el('li', { class: 'fc-listen dim', 'data-fc': 'listening' }, el('span', { class: recording ? 'fc-dot' : 'fc-dot off' }), listen));
|
|
373
|
+
if (comp.error) rows.push(el('li', { class: 'fc-listen dim', role: 'status' }, comp.error));
|
|
374
|
+
return rows;
|
|
375
|
+
});
|
|
376
|
+
const nNotes = shown.length + asks.length;
|
|
377
|
+
if (changed && nNotes > F.seenNotes) { F.r.stream.scrollTop = F.r.stream.scrollHeight; }
|
|
378
|
+
F.seenNotes = nNotes;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function askForm() {
|
|
382
|
+
const input = el('input', { type: 'text', 'aria-label': 'Ask PMOS, only you see it', placeholder: 'Ask PMOS quietly', maxlength: '500', autocomplete: 'off', 'data-fc': 'ask-input' });
|
|
383
|
+
const form = el('form', { class: 'fc-in', 'data-fc': 'ask-form' }, input, ib('Ask', 'next', { type: 'submit', 'data-fc': 'ask' }));
|
|
384
|
+
form.addEventListener('submit', (e) => { e.preventDefault(); const q = input.value.trim(); if (!q) return; input.value = ''; ask(q); });
|
|
385
|
+
return form;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function ask(question) {
|
|
389
|
+
const f = F; if (!f || !f.data) return;
|
|
390
|
+
const body = { event: f.data.event.id, date: f.date, question };
|
|
391
|
+
if (f.data.note) body.note = f.data.note.id;
|
|
392
|
+
if (wsParam()) body.ws = wsParam();
|
|
393
|
+
const at = nowMs();
|
|
394
|
+
const r = await pmPost('/api/meeting-focus/ask', body);
|
|
395
|
+
if (F !== f) return;
|
|
396
|
+
if (!r || !r.ok) { toast({ text: (r && r.error) || 'The question was not sent.', tone: 'error' }); return; }
|
|
397
|
+
f.ui.pending.set(r.id, { question, at });
|
|
398
|
+
draw();
|
|
399
|
+
const t = setInterval(async () => {
|
|
400
|
+
const s = await pmGet('/api/meeting-focus/ask?id=' + encodeURIComponent(r.id));
|
|
401
|
+
if (F !== f) { clearInterval(t); return; }
|
|
402
|
+
if (s && s.ok && s.state === 'working') return;
|
|
403
|
+
clearInterval(t); f.askTimers.delete(r.id);
|
|
404
|
+
if (s && s.ok && s.state === 'answered') f.ui.pending.delete(r.id);
|
|
405
|
+
else f.ui.failed.set(r.id, (s && s.error) || 'The agent did not answer.');
|
|
406
|
+
await load();
|
|
407
|
+
}, 1500);
|
|
408
|
+
f.askTimers.set(r.id, t);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* ── the clock ────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
412
|
+
|
|
413
|
+
function tick() {
|
|
414
|
+
if (!F || !F.data) return;
|
|
415
|
+
const d = F.data; const n = nowMs(); const s = startMs(); const f = endMs();
|
|
416
|
+
if (phaseOf() !== F.sig.phase) { F.sig.phase = phaseOf(); if (F.sig.phaseSet) { draw(); return; } F.sig.phaseSet = true; }
|
|
417
|
+
const set = (k, v) => { for (const x of document.querySelectorAll('[data-fc="' + k + '"]')) if (x.textContent !== v) x.textContent = v; };
|
|
418
|
+
const ph = phaseOf();
|
|
419
|
+
if (ph === 'before') set('big', clockText((s - n) / 1000));
|
|
420
|
+
else { set('big', clockText((n - s) / 1000)); if (Number.isFinite(f)) set('left', clockText((f - n) / 1000)); }
|
|
421
|
+
const total = Number.isFinite(f) ? (f - s) / 60000 : 60;
|
|
422
|
+
const nm = F.root.querySelector('[data-fc="nowm"]'); if (nm) nm.style.setProperty('--fc-left', Math.max(0, Math.min(100, (n - s) / 60000 / total * 100)) + '%');
|
|
423
|
+
const run = openRunOf(d);
|
|
424
|
+
if (run) {
|
|
425
|
+
set('onfor', clockText((n - (s + run.from_min * 60000)) / 1000));
|
|
426
|
+
const cr = F.root.querySelector('[data-fc="currun"]'); if (cr) cr.style.setProperty('--fc-width', Math.min(100, Math.max(0, ((n - s) / 60000 - run.from_min) / total * 100)) + '%');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* ── side sheets: Topics and Captured ─────────────────────────────────────────────────────────────────────────── */
|
|
431
|
+
|
|
432
|
+
function refreshSheet() {
|
|
433
|
+
if (!F || !F.sheetOpen || !F.sheet) return;
|
|
434
|
+
const b = F.sheet.body;
|
|
435
|
+
if (!b.isConnected) return;
|
|
436
|
+
const a = document.activeElement;
|
|
437
|
+
if (a && b.contains(a) && a.matches('input, textarea')) return; // not under the cursor
|
|
438
|
+
put(b, ...F.sheet.build());
|
|
439
|
+
}
|
|
440
|
+
function showSheet(kind, title, ic, build) {
|
|
441
|
+
const s = openSheet({ title, icon: ic, onClose: () => { if (F) { F.sheetOpen = false; F.sheet = null; F.ui.picker = false; F.ui.newTopic = false; F.ui.addTopic = false; } } });
|
|
442
|
+
F.sheet = { kind, body: s.body, build }; F.sheetOpen = true;
|
|
443
|
+
put(s.body, ...build());
|
|
444
|
+
return s;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function postTopic(action, topic) {
|
|
448
|
+
const f = F; if (!f || !f.data) return false;
|
|
449
|
+
const body = { event: f.data.event.id, date: f.date, action, topic };
|
|
450
|
+
if (f.data.note) body.note = f.data.note.id;
|
|
451
|
+
if (wsParam()) body.ws = wsParam();
|
|
452
|
+
const r = await pmPost('/api/meeting-focus/topic', body);
|
|
453
|
+
if (F !== f) return false;
|
|
454
|
+
if (!r || !r.ok) { toast({ text: (r && r.error) || 'The correction was not saved.', tone: 'error' }); return false; }
|
|
455
|
+
f.ui.picker = false; f.ui.newTopic = false; f.ui.addTopic = false;
|
|
456
|
+
await load();
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function inlineForm(label, placeholder, onSubmit, attr) {
|
|
461
|
+
const input = el('input', { type: 'text', 'aria-label': label, placeholder, maxlength: '120', 'data-fc': attr, autocomplete: 'off' });
|
|
462
|
+
const form = el('form', { class: 'fc-addf' }, input, pbtn('primary', 'Add', null, { type: 'submit', 'data-fc': attr + '-go' }));
|
|
463
|
+
form.addEventListener('submit', (e) => { e.preventDefault(); const v = input.value.trim(); if (v) onSubmit(v); });
|
|
464
|
+
queueMicrotask(() => { if (input.isConnected) input.focus(); });
|
|
465
|
+
return form;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function topicsBuild() {
|
|
469
|
+
const d = F.data; const ph = phaseOf();
|
|
470
|
+
const cur = currentOf(d);
|
|
471
|
+
const rows = d.topics.map((t, i) => {
|
|
472
|
+
const now = t.state === 'now';
|
|
473
|
+
const ki = t.state === 'discussed' ? icon('check') : now ? icon('play') : t.state === 'parked' ? icon('clock') : el('span', { class: 'mono' }, String(i + 1));
|
|
474
|
+
const chips = [
|
|
475
|
+
t.src === 'added' ? el('span', { class: 'tag fc-acc' }, icon('plus', 'sm'), 'came up ' + (t.at ? hhmm(t.at) : '') + (t.by && t.by !== 'PMOS' ? ' · ' + t.by : '')) : t.src === 'pm' ? el('span', { class: 'tag fc-acc' }, icon('plus', 'sm'), 'added by you') : el('span', { class: 'tag' }, t.src === 'last' ? 'from the last note' : 'from the invite'),
|
|
476
|
+
t.state === 'discussed' ? el('span', { class: 'tag' }, t.minutes + ' min') : null,
|
|
477
|
+
t.state === 'parked' ? el('span', { class: 'tag fc-warn' }, 'parked') : null,
|
|
478
|
+
t.state === 'notyet' && cur ? el('span', { class: 'tag' }, 'not yet') : null,
|
|
479
|
+
];
|
|
480
|
+
const run = now ? openRunOf(d) : null;
|
|
481
|
+
return el('li', { class: 'nrow fc-ai' + (now ? ' cur' : '') + (t.state === 'discussed' || t.state === 'parked' ? ' done' : ''), 'data-fc-topic': t.id, 'data-state': t.state, ...(now ? { 'aria-current': 'true' } : {}) },
|
|
482
|
+
el('span', { class: 'ki' + (now ? ' hot' : '') }, ki),
|
|
483
|
+
el('div', { class: 'nb' }, el('span', { class: 'tt' }, t.text), el('div', { class: 'chips' }, ...chips),
|
|
484
|
+
now ? [el('div', { class: 'fc-cd' }, el('b', { class: 'mono', 'data-fc': 'onfor' }, run ? clockText((nowMs() - (startMs() + run.from_min * 60000)) / 1000) : ''), el('span', { class: 'dim mono' }, 'on this' + (t.minutes > 0 && run ? '' : ''))),
|
|
485
|
+
t.by === 'PMOS' || t.src === 'added' ? el('p', { class: 'dim fc-src' }, icon('spark', 'sm'), ' PMOS picked this up from the talk.') : null,
|
|
486
|
+
el('div', { class: 'iacts' }, pbtn('outline', 'Not this topic', null, { 'data-fc': 'picker', 'aria-expanded': String(F.ui.picker) }), pbtn('ghost', 'Park it', 'clock', { 'data-fc': 'park' })),
|
|
487
|
+
F.ui.picker ? el('div', { class: 'fc-pick', role: 'group', 'aria-label': 'The room is on' }, ...d.topics.filter((x) => x.id !== t.id).map((x) => lk({ 'data-fc': 'pick', 'data-text': x.text }, topicNo(d, x.id) + '. ' + x.text)),
|
|
488
|
+
lk({ 'data-fc': 'new-topic' }, icon('plus', 'sm'), 'Something new')) : null,
|
|
489
|
+
F.ui.newTopic ? inlineForm('The topic the room is on', 'Name the topic', (v) => postTopic('set', v), 'new-input') : null] : null),
|
|
490
|
+
el('span'));
|
|
491
|
+
});
|
|
492
|
+
const added = d.topics.filter((t) => t.src === 'added').length;
|
|
493
|
+
const from = d.topics.some((t) => t.src === 'steps') ? "the meeting's steps" : d.topics.some((t) => t.src === 'purpose') ? "the meeting's purpose" : 'the meeting card';
|
|
494
|
+
return [sect({ ic: 'todo', title: 'Topics', count: d.topics.length, right: pbtn('ghost', 'Add topic', 'plus', { 'data-fc': 'add-topic' }), attrs: { 'data-fc': 'topics-sheet' } },
|
|
495
|
+
F.ui.addTopic ? inlineForm('A new topic', 'Name the topic', (v) => postTopic('add', v), 'add-input') : null,
|
|
496
|
+
rows.length ? el('ul', { class: 'list', role: 'list' }, ...rows) : el('div', { class: 'fold' }, 'No topics yet. Add one, or PMOS adds topics as they come up.'),
|
|
497
|
+
el('p', { class: 'dim fc-src' }, 'Starts from ' + from + (added ? '; ' + added + ' added from the talk' : '') + '. PMOS marks the topic the room is on and adds topics that come up. Topics have no time limits; the minutes show how long each one took.'))];
|
|
498
|
+
}
|
|
499
|
+
function capturedBuild() { return [sect({ ic: 'check', title: 'Captured so far', attrs: { 'data-fc': 'captured-sheet' } }, ...capturedBody(F.data))]; }
|
|
500
|
+
|
|
501
|
+
/* ── opening what a chip or an action names ───────────────────────────────────────────────────────────────────── */
|
|
502
|
+
|
|
503
|
+
function openUrl(url) { try { window.open(classify(url).url, '_blank', 'noopener,noreferrer'); } catch { /* a blocked pop-up */ } }
|
|
504
|
+
const goTab = (id) => { const b = document.querySelector('[data-c3-tab="' + id + '"]'); if (b) b.click(); };
|
|
505
|
+
function openRef(ref) {
|
|
506
|
+
if (!ref) return;
|
|
507
|
+
const t = ref.type;
|
|
508
|
+
closeFocus();
|
|
509
|
+
if (t === 'initiative') { import('./c3-init-extras.js').then((m) => { if (!m.goInitiative(ref.id)) goTab('init'); }).catch(() => goTab('init')); return; }
|
|
510
|
+
if (t === 'note') { window.dispatchEvent(new CustomEvent('c3-open-note', { detail: { id: ref.id } })); return; }
|
|
511
|
+
if (t === 'item') { goTab('work'); return; }
|
|
512
|
+
if (t === 'plan-item' || t === 'release' || t === 'jira' || t === 'ask') {
|
|
513
|
+
if (ref.initiative) import('./c3-init-extras.js').then((m) => { if (!m.goInitiative(ref.initiative)) goTab('init'); }).catch(() => goTab('init')); else goTab('init');
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (t === 'report') { goTab('rep'); return; }
|
|
517
|
+
if (t === 'event') { goTab('cal'); return; }
|
|
518
|
+
goTab(t === 'decision' ? 'brief' : 'init');
|
|
519
|
+
}
|
|
520
|
+
function openChip(c) {
|
|
521
|
+
if (!c) return;
|
|
522
|
+
if (c.ref) { openRef(c.ref); return; }
|
|
523
|
+
if (c.url) { openUrl(c.url); return; }
|
|
524
|
+
showSheet('chip', c.label, 'doc', () => [el('div', { class: 'fc-chipsheet', 'data-fc': 'chip-sheet' }, el('p', { class: 'fc-p' }, c.detail || c.sub || c.label), c.from ? el('p', { class: 'dim fc-note' }, 'From ' + c.from + '.') : null)]);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/* ── recording ────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
528
|
+
|
|
529
|
+
async function startRecording() {
|
|
530
|
+
const f = F; if (!f || !f.data) return;
|
|
531
|
+
const e = f.data.event;
|
|
532
|
+
if (isDemo()) { const m = await import('./shell-recording.js'); m.simStart(e.title); return; }
|
|
533
|
+
const dock = await import('./note-dock.js');
|
|
534
|
+
const asEvent = { ...(f.ev0 || {}), id: baseId(e.id), start: e.at, end: e.end || null, title: e.title, attendees: e.attendees || [], initiative: (f.ev0 && f.ev0.initiative) || e.initiative || null };
|
|
535
|
+
await dock.recordEvent(asEvent);
|
|
536
|
+
setTimeout(() => { if (F === f) load(); }, 600);
|
|
537
|
+
}
|
|
538
|
+
async function endMeeting() {
|
|
539
|
+
const f = F; if (!f) return;
|
|
540
|
+
if (isDemo()) { const m = await import('./shell-recording.js'); m.simDock.stopNow(); return; }
|
|
541
|
+
const dock = await import('./note-dock.js');
|
|
542
|
+
await dock.stopNow();
|
|
543
|
+
setTimeout(() => { if (F === f) load(); }, 600);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/* ── presses ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
547
|
+
|
|
548
|
+
function onClick(ev) {
|
|
549
|
+
const t = ev.target.closest('[data-fc]'); if (!t || !F || !F.root.contains(t)) return;
|
|
550
|
+
const k = t.dataset.fc; const d = F.data;
|
|
551
|
+
if (k === 'leave') { closeFocus(); return; }
|
|
552
|
+
if (!d) return;
|
|
553
|
+
if (k === 'topics') { showSheet('topics', 'Topics', 'todo', topicsBuild); return; }
|
|
554
|
+
if (k === 'captured') { showSheet('captured', 'Captured so far', 'check', capturedBuild); return; }
|
|
555
|
+
if (k === 'note') { const id = d.note && d.note.id; closeFocus(); if (id) window.dispatchEvent(new CustomEvent('c3-open-note', { detail: { id } })); return; }
|
|
556
|
+
if (k === 'chip') { openChip(F.chips[Number(t.dataset.i)]); return; }
|
|
557
|
+
if (k === 'preview-on') { const ls = linksOf(d); const pk = ls[F.ui.stage] || ls.find((l) => l.preview) || ls[0]; F.ui.preview = pk && pk.url; drawStage(d, phaseOf()); return; }
|
|
558
|
+
if (k === 'material-pick') { F.ui.stage = Number(t.dataset.i) || 0; drawStage(d, phaseOf()); return; }
|
|
559
|
+
if (k === 'open-link' || k === 'present') { openUrl(t.dataset.url); return; }
|
|
560
|
+
if (k === 'start') { startRecording(); return; }
|
|
561
|
+
if (k === 'end') { endMeeting(); return; }
|
|
562
|
+
if (k === 'fold') { F.ui.folded.add(t.dataset.id); drawConversation(d); return; }
|
|
563
|
+
if (k === 'unfold') { const id = t.dataset.id; if (F.ui.open.has(id)) F.ui.open.delete(id); else F.ui.open.add(id); F.ui.folded.delete(id); drawConversation(d); return; }
|
|
564
|
+
if (k === 'pull') {
|
|
565
|
+
const id = t.dataset.id; F.ui.pulled.push(id); (F.pulledAt ||= {})[id] = nowMs(); F.ui.folded.clear();
|
|
566
|
+
for (const x of document.querySelectorAll('[data-fc="held"]')) x.open = false; F.ui.heldOpen = false;
|
|
567
|
+
drawConversation(d); return;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// the side sheets live outside the page's root, so their presses are caught on the document
|
|
572
|
+
document.addEventListener('click', (ev) => {
|
|
573
|
+
if (F && ev.target.closest && F.root.contains(ev.target)) { onClick(ev); return; }
|
|
574
|
+
if (!F || !F.sheetOpen || !F.sheet) return;
|
|
575
|
+
const t = ev.target.closest && ev.target.closest('[data-shell="sheet"] [data-fc]'); if (!t) return;
|
|
576
|
+
const k = t.dataset.fc;
|
|
577
|
+
if (k === 'picker') { F.ui.picker = !F.ui.picker; F.ui.newTopic = false; refreshSheet(); return; }
|
|
578
|
+
if (k === 'new-topic') { F.ui.newTopic = true; refreshSheet(); return; }
|
|
579
|
+
if (k === 'pick') { const text = t.dataset.text; postTopic('set', text).then((ok) => { if (ok) { closeSheet(); toast('Now on: ' + text + '.'); } }); return; }
|
|
580
|
+
if (k === 'park') {
|
|
581
|
+
const cur = currentOf(F.data); if (!cur) return;
|
|
582
|
+
const others = F.data.topics.some((x) => x.id !== cur.id && x.state === 'notyet');
|
|
583
|
+
postTopic('park', cur.text).then((ok) => { if (ok) closeSheet(); return ok; }).then((ok) => { if (ok) toast(others ? 'Parked. It stays in the note as an open topic.' : 'Parked. Every other topic is covered.'); });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (k === 'add-topic') { F.ui.addTopic = !F.ui.addTopic; refreshSheet(); return; }
|
|
587
|
+
});
|