create-pmos 0.72.1 → 0.74.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.
@@ -0,0 +1,137 @@
1
+ /* pm/note-dock.js — the meeting note being taken, kept small at the bottom right of the workspace (meeting-notes Run 6).
2
+ *
3
+ * Take notes on a calendar event makes the note and starts listening at once. The note stays minimized as a pill at the
4
+ * bottom right: a red dot while it listens, its title and its time. A press on the pill opens the note as a pop-up: the same
5
+ * note as the Notes tab, in one column (pm/view-notes.js renderInline). Minimize or Esc puts it back; Open in Notes shows it
6
+ * on the full screen. The pill stays on every destination.
7
+ *
8
+ * One note at a time. The note is kept in this browser (KEY), so it is still there after a reload; a note that no longer
9
+ * listens can be put away with the x. Starting another note while one is listening asks first.
10
+ *
11
+ * mountDock() → draws the dock on every render (pm/app.js boot)
12
+ * takeNotes(e, opts, btn) → makes a note linked to the event, starts listening, and keeps it minimized
13
+ * openDock(id) → shows a note in the pop-up
14
+ * minimize() · activeId()
15
+ */
16
+ import { el } from './dom.js';
17
+ import { button, iconButton, layer, toast, ensureComponentsCss } from './primitives.js';
18
+ import { askConfirm } from './dialogs.js';
19
+ import { pmPost } from './http.js';
20
+ import { state, setState, subscribe, FIXTURE } from './state.js';
21
+ import { meetingOf, refreshNotes, noteById } from './notes-links.js';
22
+
23
+ const KEY = 'pmos.workspace.pm.note';
24
+ let active = read(); // { id, title }: the one note in the dock
25
+ let open = false; // the pop-up is open
26
+ let host = null;
27
+ let V = null; // pm/view-notes.js, read the first time the dock draws a note
28
+ let tick = null;
29
+ const viewCtx = { state, setState, FIXTURE };
30
+
31
+ function read() {
32
+ try { const j = JSON.parse(localStorage.getItem(KEY) || 'null'); return j && typeof j.id === 'string' ? { id: j.id, title: String(j.title || '') } : null; } catch { return null; }
33
+ }
34
+ function write() {
35
+ try { if (active) localStorage.setItem(KEY, JSON.stringify(active)); else localStorage.removeItem(KEY); } catch { /* private mode: the note stays for this page */ }
36
+ }
37
+ function view() {
38
+ if (!V) import('./view-notes.js').then((m) => { V = m; setState({}); });
39
+ return V;
40
+ }
41
+ const liveId = () => (V ? V.listeningId() : null);
42
+ const titleOf = () => { if (!active) return ''; const r = noteById(active.id); return (V && V.titleOf(active.id)) || (r && r.title) || active.title || 'Untitled note'; };
43
+
44
+ export const activeId = () => (active ? active.id : null);
45
+
46
+ export function mountDock() {
47
+ if (host || FIXTURE || typeof document === 'undefined') return;
48
+ ensureComponentsCss();
49
+ host = el('div', { class: 'nt-dock', 'data-nt-dock': '' });
50
+ // Esc in the pop-up minimizes it. It is read first (capture), so a field does not take it to undo the typing, and the shell
51
+ // does not also read it.
52
+ host.addEventListener('keydown', (e) => { if (e.key === 'Escape' && open) { e.preventDefault(); e.stopPropagation(); minimize(); } }, true);
53
+ const l = layer();
54
+ if (l) l.appendChild(host);
55
+ subscribe(draw);
56
+ if (active) view();
57
+ draw();
58
+ }
59
+
60
+ export function minimize() {
61
+ if (!open) return;
62
+ open = false;
63
+ if (V) V.setPopped(null);
64
+ setState({});
65
+ }
66
+ /** Show this note in the pop-up. Another note that is listening is asked about first. */
67
+ export async function openDock(id) {
68
+ if (active && active.id !== id && liveId() === active.id && !await switchFrom()) { openDock(active.id); return; }
69
+ const r = noteById(id);
70
+ active = { id, title: (r && r.title) || '' }; write();
71
+ open = true;
72
+ const v = V || await import('./view-notes.js');
73
+ V = v; v.setPopped(id);
74
+ setState({});
75
+ requestAnimationFrame(() => { const t = host && host.querySelector('[data-nt-body]'); if (t) t.focus(); });
76
+ }
77
+ function close() {
78
+ if (liveId() === (active && active.id)) return;
79
+ active = null; open = false; write();
80
+ if (V) V.setPopped(null);
81
+ setState({});
82
+ }
83
+ /** Ask before a new note replaces the one that is listening. True when the PM stopped it. */
84
+ async function switchFrom() {
85
+ const sure = await askConfirm({ title: '"' + titleOf() + '" is still being taken.', body: 'Only one note is taken at a time.', confirmLabel: 'Stop it and start this one', cancelLabel: 'Keep it' });
86
+ if (!sure) return false;
87
+ if (V) await V.stopListening(viewCtx);
88
+ return true;
89
+ }
90
+
91
+ /** Take notes on a calendar event: make the note, start listening at once, and keep it minimized. */
92
+ export async function takeNotes(e, { ws, repo, initiative } = {}, btn) {
93
+ if (active && liveId() === active.id && !await switchFrom()) { openDock(active.id); return; }
94
+ if (btn) btn.disabled = true;
95
+ const ini = initiative && initiative.id ? { id: initiative.id, ws: ws || null, repo: repo || null, title: initiative.title || null } : undefined;
96
+ const r = await pmPost('/api/notes/new', { meeting: meetingOf(e), ...(ini ? { initiative: ini } : {}) });
97
+ if (btn) btn.disabled = false;
98
+ if (!r || !r.ok) { toast({ text: (r && r.error) || 'The note was not made.', tone: 'destructive' }); return; }
99
+ active = { id: r.note.id, title: r.note.title || '' }; write();
100
+ open = false;
101
+ const v = V || await import('./view-notes.js');
102
+ V = v; v.setPopped(null);
103
+ refreshNotes();
104
+ setState({});
105
+ if (await v.listenNow(viewCtx, r.note.id)) toast({ text: 'Notes are being taken. Let people know.' });
106
+ }
107
+
108
+ function clockText() {
109
+ const s = V && V.liveStarted && V.liveStarted();
110
+ return s ? V.fmtTime(Date.now() - s) : '';
111
+ }
112
+ function draw() {
113
+ if (!host) return;
114
+ const v = view();
115
+ const live = !!active && !!v && liveId() === active.id;
116
+ clearInterval(tick); tick = null;
117
+ if (!active || !v) { host.replaceChildren(); return; }
118
+ if (live) tick = setInterval(() => { const c = host.querySelector('[data-nt-dock-clock]'); if (c) c.textContent = clockText(); }, 1000);
119
+ if (!open) {
120
+ const pill = button({ label: el('span', { class: 'nt-pill__txt' },
121
+ live ? el('span', { class: 'nt-live-dot', 'aria-hidden': 'true' }) : null,
122
+ el('span', { class: 'nt-pill__title' }, titleOf()),
123
+ live ? el('span', { class: 'nt-pill__clock', 'data-nt-dock-clock': '' }, clockText()) : null),
124
+ variant: 'outline', attrs: { class: 'nt-pill', 'data-nt-pill': live ? 'live' : 'idle', 'aria-label': (live ? 'Notes are being taken: ' : 'Meeting note: ') + titleOf() + '. Open the note.' },
125
+ onClick: () => openDock(active.id) });
126
+ host.replaceChildren(el('div', { class: 'nt-pillrow' }, pill,
127
+ live ? null : iconButton({ icon: 'x', ariaLabel: 'Put the note away', onClick: close, attrs: { 'data-nt-dock-close': '' } })));
128
+ return;
129
+ }
130
+ const body = el('div', { class: 'nt-pop__body' });
131
+ const pop = el('section', { class: 'nt-pop', role: 'dialog', 'aria-label': 'Meeting note', 'data-nt-pop': active.id },
132
+ el('div', { class: 'nt-pop__head' }, el('span', { class: 'nt-sec__h' }, live ? 'Taking notes' : 'Meeting note'),
133
+ button({ label: 'Minimize', variant: 'ghost', size: 'sm', onClick: minimize, attrs: { 'data-nt-minimize': '' } })),
134
+ body);
135
+ host.replaceChildren(pop);
136
+ if (!v.renderInline(body, viewCtx, active.id)) body.appendChild(el('p', { class: 'nt-hint' }, 'Another note is being taken.'));
137
+ }
@@ -2,27 +2,26 @@
2
2
  *
3
3
  * notesFor(e) → the note rows linked to a calendar event (same id, or same start minute and title)
4
4
  * notesOfInitiative(id) → the note rows linked to an initiative, newest first
5
+ * noteById(id) → the note's row, or null
5
6
  * notesBlock(e, { ws, repo, initiative }) → the "Meeting notes" block of an event's detail. Take notes makes a note linked
6
- * to the event and runs it right there (typing and listening, pm/view-notes.js renderInline);
7
- * an event that has a note shows its summary and Open notes, which opens it there too
7
+ * to the event, starts listening and keeps the note minimized at the bottom right
8
+ * (pm/note-dock.js, Run 6); an event that has a note shows its summary and Open notes,
9
+ * which opens it as a pop-up
8
10
  * noteRows(rows, { onOpen }) → the rows of an initiative's Notes tab; onOpen runs before the note opens
9
11
  * openNote(id) → opens the Notes tab on that note
10
12
  * onNotes(fn) · refreshNotes() → redraw when the list is read
11
13
  *
12
14
  * One read feeds it: GET /api/notes (a row carries the meeting, the initiative and up to three summary lines, never the body;
13
- * a private note carries its title only). Take notes writes POST /api/notes/new { meeting, initiative }.
15
+ * a private note carries its title only). Take notes (pm/note-dock.js) writes POST /api/notes/new { meeting, initiative }.
14
16
  */
15
17
  import { el } from './dom.js';
16
18
  import { button } from './primitives.js';
17
- import { pmGet, pmPost } from './http.js';
18
- import { state, setState, FIXTURE } from './state.js';
19
+ import { pmGet } from './http.js';
20
+ import { takeNotes, openDock, activeId } from './note-dock.js';
19
21
 
20
22
  const READ_MS = 15000; // the list is read again at most this often while a calendar or an initiative is drawn
21
23
  let rows = null; let readAt = 0; let reading = null; let failed = false;
22
24
  const subs = new Set();
23
- let shown = null; // the note open in an event's detail (Take notes or Open notes); the listening note is shown too
24
- let V = null; // pm/view-notes.js, read the first time a note is shown in a detail
25
- const viewCtx = { state, setState, FIXTURE };
26
25
 
27
26
  export function onNotes(fn) { subs.add(fn); return () => subs.delete(fn); }
28
27
  export function refreshNotes() {
@@ -51,6 +50,7 @@ export function sameEvent(meeting, e) {
51
50
  export const notesFor = (e) => list().filter((n) => sameEvent(n.meeting, e));
52
51
  export const notesOfInitiative = (id) => list().filter((n) => n.initiative && n.initiative.id === id);
53
52
  export const notesKnown = () => rows !== null || failed;
53
+ export const noteById = (id) => (rows || []).find((n) => n.id === id) || null;
54
54
 
55
55
  /** The meeting link a calendar event gives a note. */
56
56
  export function meetingOf(e) {
@@ -63,22 +63,6 @@ export async function openNote(id) {
63
63
  v.openNote(id);
64
64
  c.openView('notes');
65
65
  }
66
- async function takeNotes(e, { ws, repo, initiative } = {}, btn) {
67
- if (btn) btn.disabled = true;
68
- const ini = initiative && initiative.id ? { id: initiative.id, ws: ws || null, repo: repo || null, title: initiative.title || null } : undefined;
69
- const r = await pmPost('/api/notes/new', { meeting: meetingOf(e), ...(ini ? { initiative: ini } : {}) });
70
- if (btn) btn.disabled = false;
71
- if (!r || !r.ok) { const { toast } = await import('./primitives.js'); toast({ text: (r && r.error) || 'The note was not made.', tone: 'destructive' }); return; }
72
- shown = r.note.id;
73
- await refreshNotes();
74
- setState({});
75
- }
76
- function show(id) { shown = id; setState({}); }
77
- function view() {
78
- if (!V) import('./view-notes.js').then((m) => { V = m; setState({}); });
79
- return V;
80
- }
81
-
82
66
  const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
83
67
  const clock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
84
68
 
@@ -88,30 +72,25 @@ export function notesBlock(e, opts = {}) {
88
72
  const ended = !!e.ended;
89
73
  if (mine.length) {
90
74
  const n = mine[0];
91
- const v = view();
92
- const live = v && v.listeningId() === n.id;
93
- if (v && (shown === n.id || live)) {
94
- const host = el('div', { class: 'td-day__notes nt-inhost', 'data-notes-block': 'inline', 'data-notes-id': n.id });
95
- const head = el('div', { class: 'nt-inhost__head' }, el('h4', null, 'Meeting notes'),
96
- live ? null : button({ label: 'Close', variant: 'ghost', size: 'sm', attrs: { 'data-notes-close': n.id }, onClick: () => show(null) }));
97
- host.appendChild(head);
98
- if (v.renderInline(host, viewCtx, n.id)) return host;
99
- }
100
75
  const lines = n.summary_lines || [];
101
- const busy = v && v.listeningId() && v.listeningId() !== n.id;
76
+ if (activeId() === n.id) {
77
+ return el('div', { class: 'td-day__notes', 'data-notes-block': 'docked', 'data-notes-id': n.id },
78
+ el('h4', null, 'Meeting notes'),
79
+ el('p', { class: 'td-day__note' }, 'This note is open at the bottom right.'),
80
+ el('div', { class: 'td-day__acts' }, button({ label: 'Open the note', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => openDock(n.id) })));
81
+ }
102
82
  return el('div', { class: 'td-day__notes', 'data-notes-block': 'linked', 'data-notes-id': n.id },
103
83
  el('h4', null, 'Meeting notes'),
104
84
  n.private ? el('p', { class: 'td-day__note' }, 'This note is private.')
105
85
  : lines.length ? el('ul', { class: 'nt-lsum' }, ...lines.map((l) => el('li', null, l)))
106
86
  : el('p', { class: 'td-day__note' }, n.segments ? 'No summary yet. Open the note and press Summarize.' : 'No summary yet.'),
107
- busy ? el('p', { class: 'td-day__note', 'data-notes-busy': '' }, 'Another meeting is being listened to. Stop it first to open this note here.') : null,
108
- busy ? null : el('div', { class: 'td-day__acts' }, button({ label: 'Open notes', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => show(n.id) })));
87
+ el('div', { class: 'td-day__acts' }, button({ label: 'Open notes', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => openDock(n.id) })));
109
88
  }
110
89
  if (!notesKnown()) return null;
111
90
  const take = button({ label: 'Take notes', variant: ended ? undefined : 'primary', size: 'sm', attrs: { 'data-notes-take': e.id || '' }, onClick: () => takeNotes(e, opts, take) });
112
91
  return el('div', { class: 'td-day__notes', 'data-notes-block': 'none' },
113
92
  el('h4', null, 'Meeting notes'),
114
- el('p', { class: 'td-day__note' }, ended ? 'No notes were taken in PMOS for this meeting.' : 'Take notes here when the meeting starts. The note is linked to this event' + (opts.initiative ? ' and its initiative.' : '.')),
93
+ el('p', { class: 'td-day__note' }, ended ? 'No notes were taken in PMOS for this meeting.' : 'Take notes when the meeting starts. PMOS listens at once and keeps the note at the bottom right. It is linked to this event' + (opts.initiative ? ' and its initiative.' : '.')),
115
94
  el('div', { class: 'td-day__acts' }, take));
116
95
  }
117
96
 
@@ -159,7 +159,7 @@ export const COPY = {
159
159
  all: 'All',
160
160
  status2: {
161
161
  waiting: 'waiting', working: 'agent working', review: 'approve', reviewAuto: 'check it', sending: 'sending', sent: 'sent',
162
- done: 'done', failed: 'failed', you: 'your act', notStarted: 'not started', handled: 'handled', replyNeeded: 'reply needed',
162
+ done: 'done', failed: 'failed', notStarted: 'not started', handled: 'handled', replyNeeded: 'reply needed',
163
163
  },
164
164
  newItem: 'new',
165
165
  pill: { prepare: 'prepare', ready: 'ready', ended: 'ended', notes: 'notes', goal: (n) => `goal ${n}` },
@@ -189,8 +189,6 @@ export const COPY = {
189
189
  prep: 'Prepare',
190
190
  phases: { before: 'Before', during: 'During', after: 'After' },
191
191
  who: { pm: 'you', agent: 'agent' },
192
- needPrep: 'You need to prepare',
193
- nothingToPrep: 'Nothing to prepare',
194
192
  initiative: 'Initiative',
195
193
  suggested: (why) => `Suggested. ${why}`,
196
194
  openInitiative: 'Open the initiative',
@@ -235,7 +233,6 @@ export const COPY = {
235
233
  waitingNote: 'The agent has not started this job yet. At most 5 sessions run at once.',
236
234
  workingNote: 'An agent session is doing this job now.',
237
235
  sendingNote: 'You said yes to this draft. An agent session is sending it now.',
238
- youNote: 'Only you can do this one. The link opens the conversation.',
239
236
  autoNote: 'The agent did this alone. Nothing went outside this machine.',
240
237
  notTask: 'The agent starts this job when you press Prepare my day.',
241
238
  related: 'Related',
@@ -351,6 +348,87 @@ export const COPY = {
351
348
  today: 'Today',
352
349
  },
353
350
  },
351
+ /** The items the PM acts on and the meetings the PM prepares for (initiative today-act-prepare, PRD A1 to D4). A verb
352
+ * first, one sentence of what to do, then the steps, one button that fits the first open step, and the marks. */
353
+ act: {
354
+ verb: { reply: 'Reply', review: 'Review', decide: 'Decide', 'sign-off': 'Sign off', read: 'Read', do: 'Do', prepare: 'Prepare', approve: 'Approve' },
355
+ groups: { later: 'Snoozed', waiting: 'Waiting on others', done: 'Done today' },
356
+ by: (at) => `by ${at}`,
357
+ at: (at) => `at ${at}`,
358
+ left: (min) => (min <= 0 ? 'now' : min < 60 ? `in ${min} min` : `in ${Math.floor(min / 60)} h ${min % 60} min`),
359
+ back: (at) => `back ${at}`,
360
+ check: (at) => `ask ${at}`,
361
+ chip: { done: 'done', ready: 'ready', dismissed: 'not mine', skipped: 'no prep', today: 'today' },
362
+ from: (who, where) => (who && where ? `${who} in ${where}` : who || where || ''),
363
+ howTitle: (d, n) => `How to do it · ${d} of ${n}`,
364
+ beforeTitle: (at) => `Before ${at}`,
365
+ more: 'More context',
366
+ moreMeeting: (during, after) => `In the meeting (${during}) · After (${after})`,
367
+ readOnly: 'Read only. Nothing is sent before you press a button that says so.',
368
+ open: { slack: 'Reply in Slack ↗', jira: 'Open in Jira ↗', confluence: 'Open in Confluence ↗', github: 'Open on GitHub ↗', figma: 'Open in Figma ↗',
369
+ mail: 'Open the mail ↗', drive: 'Open in Google Drive ↗', calendar: 'Open the event ↗', wispr: 'Open the notes ↗', website: 'Open the page ↗' },
370
+ openLink: 'Open the link ↗',
371
+ startStep: (t) => `Start: ${t}`,
372
+ askAgentStep: 'Ask the agent',
373
+ draft: 'Have the agent draft it',
374
+ drafting: 'The agent is drafting. The draft shows here, and nothing is sent before you approve it.',
375
+ draftStarted: 'The agent is drafting it. Approve or revise the draft when it is ready.',
376
+ done: 'Mark done',
377
+ ready: 'Mark ready',
378
+ snooze: 'Snooze',
379
+ remind: 'Remind me',
380
+ hold: 'Waiting on…',
381
+ notMine: 'Not mine',
382
+ skip: 'Skip prep',
383
+ snoozeTo: { hour: 'In 1 hour', later: 'Later today, 16:00', due: 'Before it is due', end: 'End of the day, 17:30', m30: '30 min before it', m15: '15 min before it' },
384
+ holdWho: 'Who or what you wait on',
385
+ holdHint: 'For example: Raj, or the cost model',
386
+ holdWhen: 'Ask again',
387
+ holdWhenOpts: { h2: 'In 2 hours', end: 'At 17:30' },
388
+ holdGo: 'Put on hold',
389
+ holdNeed: 'Say who or what you wait on, in 1 to 80 characters.',
390
+ why: { 'no-action': 'No action needed', 'someone-else': 'Someone else handles it', 'done-elsewhere': 'It is already done', nothing: 'Nothing to prepare', elsewhere: 'I prepare elsewhere' },
391
+ did: {
392
+ done: (t) => `Done: ${t}.`, ready: (t) => `Ready: ${t}.`, snooze: (at) => `Snoozed. It comes back at ${at}.`,
393
+ hold: (who, at) => `Waiting on ${who}. Today asks you again at ${at}.`, dismiss: (why) => `${why}. Taken off Needs you.`, reopen: 'Back in Needs you.',
394
+ },
395
+ undo: 'Undo',
396
+ undone: 'Put back as it was.',
397
+ state: {
398
+ snoozed: (at) => `Snoozed. It comes back to Needs you at ${at}.`,
399
+ waiting: (who, at) => `On hold: waiting on ${who}. Today asks you again at ${at}.`,
400
+ done: (at) => `Done at ${at}.`, ready: (at) => `Ready at ${at}.`,
401
+ dismissed: (why) => `Not on your list: ${why}.`,
402
+ back: 'Back from snooze or hold: the time you set has come.',
403
+ },
404
+ bringBack: 'Bring it back now',
405
+ reopen: 'Reopen',
406
+ readiness: (d, n) => `${d} of ${n} ready`,
407
+ fit: { ok: 'Your prep is done.', time: (left, free) => `${left} ${left === 1 ? 'step' : 'steps'} left, ${free} min free before it.`,
408
+ short: (left, free) => `${left} ${left === 1 ? 'step' : 'steps'} left and ${free} min free. Give a step to the agent, or snooze.` },
409
+ noSteps: 'No steps were given. The sentence above says what to do.',
410
+ stepFailed: (why) => `The step was not saved: ${why}`,
411
+ // the context and the answer in the card (PRD X1, X2, F1 to F3)
412
+ threadTitle: 'The conversation',
413
+ refsTitle: 'What the sources say',
414
+ refRead: 'Read: ',
415
+ refOpen: 'Open the source ↗',
416
+ compose: { reply: 'Your reply', review: 'Your review', decide: 'Your answer', 'sign-off': 'Your sign-off' },
417
+ composeHint: 'Write it here, or start from a suggestion.',
418
+ startFrom: 'Start from:',
419
+ verdictLabel: 'Your verdict',
420
+ verdict: { approve: 'Approve', changes: 'Ask for changes' },
421
+ sendTo: (to) => `Send to ${to}`,
422
+ postReview: (to) => `Post the review to ${to}`,
423
+ sendNote: 'When you press, the agent posts exactly this text, as you, to that place. Nothing else is sent.',
424
+ sendNeed: 'Write the text to send first.',
425
+ verdictNeed: 'Choose Approve or Ask for changes first.',
426
+ sendStarted: (to) => `Sending to ${to}. The job shows Sent when it is posted.`,
427
+ noteTitle: 'Your note',
428
+ noteHint: 'Write it here. It is saved when you leave the box.',
429
+ noteStays: 'Saved with the meeting on this machine. Nothing is sent.',
430
+ noteSaved: 'Note saved.',
431
+ },
354
432
  /** The Acceptance Gate card (D12): the PM's own act on a run. */
355
433
  accept: {
356
434
  review: 'Review',