create-pmos 0.116.0 → 0.118.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 (33) 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/state/discoveries.md +1 -1
  6. package/workspace/guide/notes.md +43 -3
  7. package/workspace/guide/today.md +22 -13
  8. package/workspace/lib/brief.js +2 -2
  9. package/workspace/lib/day-marks.js +29 -9
  10. package/workspace/lib/goals.js +68 -10
  11. package/workspace/lib/notes.js +139 -17
  12. package/workspace/lib/routes-notes.js +68 -11
  13. package/workspace/package-lock.json +2 -2
  14. package/workspace/package.json +1 -1
  15. package/workspace/public/pm/components.css +289 -91
  16. package/workspace/public/pm/dest-notes.js +136 -29
  17. package/workspace/public/pm/note-dock.js +204 -164
  18. package/workspace/public/pm/notes-context.js +60 -0
  19. package/workspace/public/pm/notes-links.js +32 -6
  20. package/workspace/public/pm/primitives.js +1 -1
  21. package/workspace/public/pm/today-agenda.js +27 -53
  22. package/workspace/public/pm/today-copy.js +34 -15
  23. package/workspace/public/pm/today-day.js +98 -76
  24. package/workspace/public/pm/today-goals.js +159 -15
  25. package/workspace/public/pm/today-jobs.js +84 -0
  26. package/workspace/public/pm/today-mentions.js +50 -0
  27. package/workspace/public/pm/topbar.js +71 -32
  28. package/workspace/public/pm/view-notes.js +142 -73
  29. package/workspace/server.js +10 -8
  30. package/workspace/test-files.json +4 -0
  31. package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
  32. package/workspace/test-lists/today-goals-jobs.json +5 -0
  33. package/workspace/test-lists/today-mentions-cards.json +5 -0
@@ -1,200 +1,240 @@
1
- /* pm/note-dock.js — the meeting note being taken: the Record control's brain and the pop-up (meeting-notes Run 6, Run 12).
1
+ /* pm/note-dock.js — the meeting being recorded: the Record control's brain (meeting-notes Run 13).
2
2
  *
3
- * The Record control lives in the top bar (pm/topbar.js, mockup board 2): Record when idle, the red dot with the clock, the
4
- * highlighter and Pause while listening, Resume when paused. Take notes on a calendar event makes the note and starts
5
- * listening at once. A press on the clock opens the note as a pop-up: the same note as the Notes tab, in one column
6
- * (pm/view-notes.js renderInline). Minimize or Esc puts it away; the highlight pop-up sits bottom right on every place.
3
+ * The Record control lives in the top bar (pm/topbar.js). It has two groups. The left one exists while a meeting is recording
4
+ * or paused: a chip that names the meeting, then Highlight, Pause and Stop (recording) or Resume and Stop (paused). The right
5
+ * one is always there: New, which starts an untitled note, and an arrow that lists today's meetings that have not started.
6
+ * There is no pop-up note any more: the chip, and every start of a recording, show the Notes place with that note selected,
7
+ * and the note there is the full note (pm/dest-notes.js).
7
8
  *
8
- * One note at a time. The note is kept in this browser (KEY), so it is still there after a reload. Starting another note
9
- * while one is listening asks first.
9
+ * PAUSE keeps the meeting open and resumable for an hour (the server ends it by itself after that and writes the final
10
+ * notes). STOP ends it for good, asks first, and writes the final notes now. A stopped meeting cannot be resumed. A note
11
+ * that is stopped within a minute, or before anything was heard, with nothing typed, is deleted instead (a recording started
12
+ * by mistake must not take the place of the meeting before it). Resume applies to the most recent meeting only.
10
13
  *
11
- * mountDock() → draws the dock on every render (pm/app.js boot)
12
- * recordState() → { mode: idle | recording | paused, id, title, started } for the top bar
13
- * recordNew(btn) · recordNote(id, title) · pauseNow() · resumeNow() · finish() · highlightNow()
14
- * takeNotes(e, opts, btn) → makes a note linked to the event, starts listening
15
- * openDock(id) → shows a note in the pop-up
16
- * minimize() · activeId() · forget(id)
14
+ * What is held is read, not remembered: the listening note comes from pm/view-notes.js, and a paused meeting from the note
15
+ * rows (GET /api/notes: state, resume_until, sessions), read again every REREAD_MS and after each action.
16
+ *
17
+ * mountDock() → reads the rows now and then every REREAD_MS (pm/app.js boot)
18
+ * recordState() → { mode: idle | recording | paused, id, title, started, until } for the top bar
19
+ * recordNew(btn) · recordEvent(e, btn) · takeNotes(e, opts, btn) · recordNote(id, title)
20
+ * pauseNow() · resumeNow() · stopNow() · highlightNow() · highlightPanel()
21
+ * openDock(id) → shows the note in the Notes place
22
+ * activeId() · forget(id)
17
23
  */
18
- import { el, patchChildren } from './dom.js';
19
- import { button, iconButton, layer, toast, ensureComponentsCss } from './primitives.js';
20
- import { askConfirm } from './dialogs.js';
21
- import { pmPost } from './http.js';
24
+ import { sheet, button, toast, ensureComponentsCss } from './primitives.js';
25
+ import { el } from './dom.js';
26
+ import { pmPost, pmGet } from './http.js';
22
27
  import { state, setState, subscribe, FIXTURE } from './state.js';
23
- import { meetingOf, refreshNotes, noteById } from './notes-links.js';
24
-
25
- const KEY = 'pmos.workspace.pm.note';
26
- let active = read(); // { id, title }: the one note in the dock
27
- let open = false; // the pop-up is open
28
- let host = null;
29
- let V = null; // pm/view-notes.js, read the first time the dock draws a note
30
- let tick = null;
28
+ import { meetingOf, refreshNotes, noteById, notesRows, onNotes } from './notes-links.js';
29
+
30
+ const REREAD_MS = 30000;
31
+ const MIN = 60000;
32
+ let V = null; // pm/view-notes.js, read the first time it is needed
33
+ let mounted = false;
31
34
  const viewCtx = { state, setState, FIXTURE };
32
35
 
33
- function read() {
34
- 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; }
35
- }
36
- function write() {
37
- try { if (active) localStorage.setItem(KEY, JSON.stringify(active)); else localStorage.removeItem(KEY); } catch { /* private mode: the note stays for this page */ }
38
- }
39
36
  function view() {
40
37
  if (!V) import('./view-notes.js').then((m) => { V = m; setState({}); });
41
38
  return V;
42
39
  }
40
+ const loadView = async () => { if (!V) V = await import('./view-notes.js'); return V; };
43
41
  const liveId = () => (V ? V.listeningId() : null);
44
- const titleOf = () => { if (!active) return ''; const r = noteById(active.id); return (V && V.titleOf(active.id)) || (r && r.title) || active.title || 'Untitled note'; };
45
-
46
- export const activeId = () => (active ? active.id : null);
47
- export const isRecording = () => !!(active && V && liveId() === active.id);
48
42
 
49
43
  export function mountDock() {
50
- if (host || FIXTURE || typeof document === 'undefined') return;
44
+ if (mounted || FIXTURE || typeof document === 'undefined') return;
45
+ mounted = true;
51
46
  ensureComponentsCss();
52
- host = el('div', { class: 'nt-dock', 'data-nt-dock': '' });
53
- // 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
54
- // does not also read it.
55
- host.addEventListener('keydown', (e) => { if (e.key === 'Escape' && open) { e.preventDefault(); e.stopPropagation(); minimize(); } }, true);
56
- const l = layer();
57
- if (l) l.appendChild(host);
58
- subscribe(draw);
59
- if (active) view();
60
- draw();
61
- }
62
-
63
- export function minimize() {
64
- if (!open) return;
65
- open = false;
66
- if (V) V.setPopped(null);
67
- setState({});
68
- }
69
- /** Show this note in the pop-up. Another note that is listening is asked about first. */
70
- export async function openDock(id) {
71
- if (active && active.id !== id && liveId() === active.id && !await switchFrom()) { openDock(active.id); return; }
72
- const r = noteById(id);
73
- active = { id, title: (r && r.title) || '' }; write();
74
- open = true;
75
- const v = V || await import('./view-notes.js');
76
- V = v; v.setPopped(id);
77
- setState({});
78
- requestAnimationFrame(() => { const t = host && host.querySelector('[data-nt-body]'); if (t) t.focus(); });
79
- }
80
- /** A note was deleted: when it is the one in the dock, the dock lets it go. */
81
- export function forget(id) {
82
- if (!active || active.id !== id) return;
83
- active = null; open = false; write();
84
- if (V) V.setPopped(null);
85
- setState({});
86
- }
87
- function close() {
88
- if (liveId() === (active && active.id)) return;
89
- active = null; open = false; write();
90
- if (V) V.setPopped(null);
91
- setState({});
92
- }
93
- /** Ask before a new note replaces the one that is listening. True when the PM stopped it. */
94
- async function switchFrom() {
95
- 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' });
96
- if (!sure) return false;
97
- if (V) await V.stopListening(viewCtx);
98
- return true;
47
+ // a meeting that was recording when the page closed is found again from the server
48
+ refreshNotes();
49
+ setInterval(() => refreshNotes(), REREAD_MS);
50
+ onNotes(() => setState({}));
51
+ subscribe(() => { if (!V && notesRows().some((n) => n.state === 'recording')) view(); });
99
52
  }
100
53
 
101
- /* ── the Record control at the right of the top bar (meeting-notes Run 12, mockup board 2) ────────────────────────────
102
- * It replaces the pill: idle it says Record; while the note listens it shows the red dot and the clock (a press opens the
103
- * note), the highlight and Pause; paused it shows the clock and Resume. Stop, in the note, ends it and the control is idle
104
- * again. The note it holds is the dock's one note (active). */
54
+ /* ── what is held ─────────────────────────────────────────────────────────────────────────────────────────────────── */
105
55
 
106
- /** { mode: 'idle'|'recording'|'paused', id, title, started } — what the Record control shows. */
107
- export function recordState() {
108
- if (!active) return { mode: 'idle', id: null, title: '', started: null };
109
- const live = !!V && liveId() === active.id;
110
- return { mode: live ? 'recording' : 'paused', id: active.id, title: titleOf(), started: live && V.liveStarted ? V.liveStarted() : null };
56
+ /** The moment a note row was last recorded: the end of its last stretch, or now while a stretch is open. Pure. */
57
+ export function lastRecorded(row, now = Date.now()) {
58
+ const s = (row && row.sessions) || [];
59
+ if (!s.length) return null;
60
+ const last = s[s.length - 1];
61
+ if (!last.to) return now;
62
+ const t = Date.parse(last.to);
63
+ return Number.isFinite(t) ? t : null;
64
+ }
65
+ /** The most recent recorded meeting among the rows. Pure. */
66
+ export function lastMeeting(rows, now = Date.now()) {
67
+ let best = null; let at = -1;
68
+ for (const r of rows || []) {
69
+ const t = lastRecorded(r, now);
70
+ if (t !== null && t > at) { best = r; at = t; }
71
+ }
72
+ return best;
73
+ }
74
+ /** The paused meeting that Resume continues: the most recent one, while its hour has not run out. A row the server calls
75
+ * recording while this page listens to nothing is a meeting the page lost (a reload): it can be resumed, with no clock. Pure. */
76
+ export function pausedOf(rows, { now = Date.now(), listening = null } = {}) {
77
+ const r = lastMeeting(rows, now);
78
+ if (!r || r.private) return null;
79
+ if (r.state === 'paused' && r.resume_until && Date.parse(r.resume_until) > now) return r;
80
+ if (r.state === 'recording' && listening !== r.id) return r;
81
+ return null;
111
82
  }
112
- /** Record: a new note, linked to nothing, that listens at once. */
113
- export async function recordNew(btn) {
114
- if (active && liveId() === active.id) { openDock(active.id); return; }
83
+
84
+ /** { mode: 'idle'|'recording'|'paused', id, title, started, until } — what the Record control shows. */
85
+ export function recordState() {
86
+ const live = liveId();
87
+ if (live) return { mode: 'recording', id: live, title: titleOf(live), started: V && V.liveStarted ? V.liveStarted() : null, until: null };
88
+ const p = pausedOf(notesRows(), { listening: live });
89
+ if (p) return { mode: 'paused', id: p.id, title: p.title || 'Untitled note', started: null, until: p.resume_until ? Date.parse(p.resume_until) : null, ms: p.meeting_ms || 0 };
90
+ return { mode: 'idle', id: null, title: '', started: null, until: null };
91
+ }
92
+ const titleOf = (id) => { const r = noteById(id); return (V && V.titleOf(id)) || (r && r.title) || 'Untitled note'; };
93
+ export const activeId = () => recordState().id;
94
+
95
+ /* ── starting ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
96
+
97
+ const blocked = () => { if (!liveId()) return false; toast({ text: 'Pause or stop the meeting being recorded first.' }); return true; };
98
+
99
+ /** Make a note and listen at once; then show it in the Notes place. `body` is the POST /api/notes/new body. */
100
+ async function startNote(body, btn) {
101
+ if (FIXTURE || blocked()) return false;
115
102
  if (btn) btn.disabled = true;
116
- const r = await pmPost('/api/notes/new', {});
103
+ const r = await pmPost('/api/notes/new', body);
117
104
  if (btn) btn.disabled = false;
118
- if (!r || !r.ok) { toast({ text: (r && r.error) || 'The note was not made.', tone: 'destructive' }); return; }
119
- await recordNote(r.note.id, r.note.title || '');
120
- refreshNotes();
105
+ if (!r || !r.ok) { toast({ text: (r && r.error) || 'The note was not made.', tone: 'destructive' }); return false; }
106
+ return begin(r.note.id);
121
107
  }
122
- /** Record or Resume on one note: it becomes the dock's note and listens. Another note that is listening is asked about first. */
123
- export async function recordNote(id, title) {
124
- if (active && active.id !== id && liveId() === active.id && !await switchFrom()) return;
125
- active = { id, title: title || '' }; write();
126
- const v = V || await import('./view-notes.js');
127
- V = v;
108
+ async function begin(id) {
109
+ const v = await loadView();
110
+ refreshNotes();
111
+ const ok = await v.listenNow(viewCtx, id);
112
+ await refreshNotes();
113
+ openDock(id);
114
+ if (ok) toast({ text: 'Notes are being taken. Let people know.' });
115
+ else toast({ text: v.takeBanner() || 'The note could not listen.', tone: 'destructive' });
128
116
  setState({});
129
- if (await v.listenNow(viewCtx, id)) toast({ text: 'Notes are being taken. Let people know.' });
117
+ return ok;
118
+ }
119
+ /** New: an untitled note, linked to nothing, that listens at once. */
120
+ export function recordNew(btn) { return startNote({}, btn); }
121
+ /** A meeting that has not started: a note made for its event, with its context kept. */
122
+ export function recordEvent(e, btn) { return startNote({ meeting: meetingOf(e) }, btn); }
123
+ /** Take notes on a calendar event (the calendar's own button): the note is linked to the event, and to its initiative when it has one. */
124
+ export function takeNotes(e, { ws, repo, initiative } = {}, btn) {
125
+ const ini = initiative && initiative.id ? { id: initiative.id, ws: ws || null, repo: repo || null, title: initiative.title || null } : undefined;
126
+ return startNote({ meeting: meetingOf(e), ...(ini ? { initiative: ini } : {}) }, btn);
127
+ }
128
+ /** Record or Resume on one note (the legacy Notes board): a paused meeting resumes, any other note listens. */
129
+ export async function recordNote(id) {
130
+ if (FIXTURE || blocked()) return false;
131
+ const v = await loadView();
132
+ const ok = await v.listenNow(viewCtx, id);
133
+ await refreshNotes();
134
+ if (!ok) toast({ text: v.takeBanner() || 'The note could not listen.', tone: 'destructive' });
130
135
  setState({});
136
+ return ok;
131
137
  }
132
- /** Pause: listening stops and the note stays here, ready to Resume. */
138
+
139
+ /* ── pause, resume, stop ──────────────────────────────────────────────────────────────────────────────────────────── */
140
+
141
+ /** Pause: listening stops and the meeting stays open for an hour. It does not ask, and writes no final notes. */
133
142
  export async function pauseNow() {
134
- if (V && active && liveId() === active.id) await V.stopListening(viewCtx);
143
+ await loadView(); // a meeting the Notes tab started is known to this page before the dock has read it
144
+ const s = recordState();
145
+ if (s.mode !== 'recording') return;
146
+ const v = await loadView();
147
+ await v.pauseListening(viewCtx);
148
+ await refreshNotes();
149
+ toast({ text: 'Paused. You can resume within 1 hour.', timeout: 3000 });
135
150
  setState({});
136
151
  }
137
- /** Resume the paused note. */
138
- export function resumeNow() { if (active) return recordNote(active.id, active.title); return null; }
139
- /** Stop: listening stops and the note leaves the Record control (the final notes are written after Stop). */
140
- export async function finish() {
141
- if (V && active && liveId() === active.id) await V.stopListening(viewCtx);
142
- active = null; open = false; write();
143
- if (V) V.setPopped(null);
152
+ /** Resume the paused meeting. The server refuses a meeting that was stopped or ran out of its hour. */
153
+ export async function resumeNow() {
154
+ await loadView();
155
+ const s = recordState();
156
+ if (s.mode !== 'paused') return false;
157
+ const v = await loadView();
158
+ const ok = await v.listenNow(viewCtx, s.id);
159
+ await refreshNotes();
160
+ if (ok) toast({ text: 'Resumed ' + s.title + '. Let people know.' });
161
+ else toast({ text: v.takeBanner() || 'The meeting could not be resumed.', tone: 'destructive' });
144
162
  setState({});
163
+ return ok;
145
164
  }
165
+
166
+ /** Is this note one that was started by mistake? Within a minute, or nothing heard, nothing typed, no highlight, one stretch. Pure. */
167
+ export function discardable(n, now = Date.now()) {
168
+ if (!n || String(n.body || '').trim() || (n.highlights || []).length) return false;
169
+ const s = n.sessions || [];
170
+ if (s.length !== 1) return false;
171
+ const from = Date.parse(s[0].from); const to = s[0].to ? Date.parse(s[0].to) : now;
172
+ const heard = (n.transcript || []).some((x) => !x.dropped);
173
+ return !heard || to - from < MIN;
174
+ }
175
+
176
+ /** The "Stop for good?" pop-up. Resolves 'stop', 'pause' (only while recording) or null. */
177
+ function askStop(title, recording) {
178
+ ensureComponentsCss();
179
+ return new Promise((resolve) => {
180
+ let settled = false; let handle = null;
181
+ const done = (v) => { if (settled) return; settled = true; resolve(v); if (handle) handle.close(); };
182
+ const actions = [button({ label: 'Cancel', variant: 'outline', onClick: () => done(null), attrs: { 'data-dialog-cancel': '' } })];
183
+ if (recording) actions.push(button({ label: 'Pause instead', variant: 'outline', onClick: () => done('pause'), attrs: { 'data-dialog-pause': '' } }));
184
+ actions.push(button({ label: 'Stop for good', variant: 'danger', onClick: () => done('stop'), attrs: { 'data-dialog-confirm': '' } }));
185
+ handle = sheet({ mode: 'auto', title: 'Stop “' + title + '” for good?',
186
+ body: [el('p', null, 'A stopped meeting cannot be resumed. The final notes are written now.'), recording ? el('p', null, 'Pause it instead if you may continue within an hour.') : null].filter(Boolean),
187
+ actions, attrs: { 'data-dialog': 'stop-meeting' }, onClose: () => done(null) });
188
+ const stopBtn = handle.dialog && handle.dialog.querySelector('[data-dialog-cancel]');
189
+ if (stopBtn) { try { stopBtn.focus({ preventScroll: true }); } catch { /* not focusable yet */ } }
190
+ });
191
+ }
192
+
193
+ /** Stop: asks first; ends the meeting for good. A recording started by mistake is deleted instead, without asking. */
194
+ export async function stopNow() {
195
+ const v = await loadView();
196
+ const s = recordState();
197
+ if (s.mode === 'idle') return;
198
+ const one = await pmGet('/api/notes/one?id=' + encodeURIComponent(s.id));
199
+ const note = one && one.ok ? one.note : null;
200
+ if (note && discardable(note)) {
201
+ const gone = await v.discardRecording(viewCtx, s.id);
202
+ await refreshNotes();
203
+ const back = pausedOf(notesRows(), { listening: liveId() });
204
+ toast({ text: gone ? 'Nothing was recorded, so the note was deleted' + (back ? '. ' + (back.title || 'The earlier meeting') + ' can be resumed again.' : '.') : 'The note was not deleted.' });
205
+ setState({});
206
+ return;
207
+ }
208
+ const choice = await askStop(s.title, s.mode === 'recording');
209
+ if (choice === 'pause') { await pauseNow(); return; }
210
+ if (choice !== 'stop') return;
211
+ await v.stopMeeting(viewCtx, s.id);
212
+ await refreshNotes();
213
+ toast({ text: 'Stopped for good. The final notes are written now.' });
214
+ setState({});
215
+ }
216
+ /** Legacy name: the note's own Stop. */
217
+ export const finish = stopNow;
218
+
146
219
  /** Highlight: the last 30 seconds and what is said next, on the note that is listening. */
147
220
  export function highlightNow() {
148
- if (!V || !active || liveId() !== active.id) return;
149
- V.highlightNow(viewCtx, active.id);
150
- }
221
+ const id = liveId();
222
+ if (!V || !id) return;
223
+ V.highlightNow(viewCtx, id);
224
+ }
225
+ /** The highlight's pop-up, for the top bar to hang under the Record control; null when there is none or it is closed. */
226
+ export const highlightPanel = () => (V && V.highlightPanel ? V.highlightPanel(viewCtx) : null);
227
+ export const highlightBusy = () => (V && V.highlightBusy ? V.highlightBusy() : null);
228
+ export const viewReady = () => { view(); return !!V; };
151
229
 
152
- /** Take notes on a calendar event: make the note, start listening at once, and keep it minimized. */
153
- export async function takeNotes(e, { ws, repo, initiative } = {}, btn) {
154
- if (active && liveId() === active.id && !await switchFrom()) { openDock(active.id); return; }
155
- if (btn) btn.disabled = true;
156
- const ini = initiative && initiative.id ? { id: initiative.id, ws: ws || null, repo: repo || null, title: initiative.title || null } : undefined;
157
- const r = await pmPost('/api/notes/new', { meeting: meetingOf(e), ...(ini ? { initiative: ini } : {}) });
158
- if (btn) btn.disabled = false;
159
- if (!r || !r.ok) { toast({ text: (r && r.error) || 'The note was not made.', tone: 'destructive' }); return; }
160
- active = { id: r.note.id, title: r.note.title || '' }; write();
161
- open = false;
162
- const v = V || await import('./view-notes.js');
163
- V = v; v.setPopped(null);
230
+ /** Show a note in the Notes place: the list by initiative, with this note open. */
231
+ export async function openDock(id) {
232
+ const r = await import('./registry.js');
233
+ r.openDestination('notes', { slug: id });
234
+ }
235
+ /** A note was deleted: the Record control lets go of it. */
236
+ export function forget(id) {
237
+ if (V && V.listeningId && V.listeningId() === id) { /* the view stops it */ }
164
238
  refreshNotes();
165
239
  setState({});
166
- if (await v.listenNow(viewCtx, r.note.id)) toast({ text: 'Notes are being taken. Let people know.' });
167
- }
168
-
169
- function clockText() {
170
- const s = V && V.liveStarted && V.liveStarted();
171
- return s ? V.fmtTime(Date.now() - s) : '';
172
- }
173
- function draw() {
174
- if (!host) return;
175
- const v = view();
176
- const live = !!active && !!v && liveId() === active.id;
177
- clearInterval(tick); tick = null;
178
- // The dock draws on every state change, and a transcript line comes in every few seconds while it listens. It draws into
179
- // a detached node and patches the screen from it, so only what changed is touched: the lines already shown, the field
180
- // being typed in, the scroll stay as they are, and a new line is appended (2026-10-08 flicker).
181
- const fresh = el('div');
182
- // the highlight's pop-up is drawn here, on every place, whether the note is open or not
183
- const hl = v && v.highlightPanel ? v.highlightPanel(viewCtx) : null;
184
- if (!active || !v) { if (hl) fresh.appendChild(hl); patchChildren(host, fresh); return; }
185
- if (live) tick = setInterval(() => { const c = host.querySelector('[data-nt-dock-clock]'); if (c) c.textContent = clockText(); }, 1000);
186
- // the Record control at the top right shows the note now (Run 12): no pill
187
- if (!open) { if (hl) fresh.appendChild(hl); patchChildren(host, fresh); return; }
188
- // the note draws its own bar (status, Stop, Minimize and the ⋯ menu): the pop-up adds no second head (Run 7)
189
- const body = el('div', { class: 'nt-pop__body' });
190
- const pop = el('section', { class: 'nt-pop', role: 'dialog', 'aria-label': 'Meeting note', 'data-nt-pop': active.id }, body);
191
- fresh.appendChild(pop);
192
- if (hl) fresh.appendChild(hl);
193
- if (!v.renderInline(body, viewCtx, active.id, { onMinimize: minimize })) body.appendChild(el('p', { class: 'nt-hint' }, 'Another note is being taken.'),
194
- button({ label: 'Minimize', variant: 'ghost', size: 'sm', onClick: minimize, attrs: { 'data-nt-minimize': '' } }));
195
- // a transcript read to its end follows the new lines; one the PM scrolled up in stays where they left it
196
- const box = host.querySelector('[data-nt-pop] [data-nt-transcript]');
197
- const atEnd = !!box && box.scrollTop + box.clientHeight >= box.scrollHeight - 8;
198
- patchChildren(host, fresh);
199
- if (atEnd && box.isConnected) box.scrollTop = box.scrollHeight;
200
240
  }
@@ -0,0 +1,60 @@
1
+ /* pm/notes-context.js — the context of a meeting, as a note keeps it (meeting-notes Run 13, R59).
2
+ *
3
+ * contextOf(e) → the snapshot a note stores when it is made for a calendar event, or null when the event gives nothing
4
+ * peopleOf(ctx) → the names in the "with" line, in order
5
+ * agendaHeard(ctx, lines) → for each agenda item, the time of the first transcript line that shares two of its words, or null
6
+ *
7
+ * WHERE IT COMES FROM. The calendar memory and the morning brief (workspace/skills/morning-brief.skill) are the only sources:
8
+ * the event's "with" line (names, only while calendar names are on), the number of attendees, the PM's role, whether someone
9
+ * outside the company attends, the series, and the brief's plan for the meeting: its purpose, how to be ready, the steps
10
+ * planned for the meeting itself (the agenda), the links and the related items. The skill forbids copying an invitation's
11
+ * text, so there is no description or location here, and nothing is made up: a field the event does not carry is absent.
12
+ * The server clips and checks every field again (lib/notes.js cleanMeeting).
13
+ */
14
+
15
+ const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
16
+ const isUrl = (u) => typeof u === 'string' && /^https?:\/\//i.test(u);
17
+
18
+ /** The snapshot of a calendar event's context. Pure. Null when the event carries none of it. */
19
+ export function contextOf(e) {
20
+ if (!e || typeof e !== 'object') return null;
21
+ const prep = Array.isArray(e.prep) ? e.prep.map((x) => (x && typeof x === 'object' ? x.t : x)).filter((x) => typeof x === 'string').join(' · ') : e.prep;
22
+ const during = e.steps && Array.isArray(e.steps.during) ? e.steps.during : [];
23
+ const c = {
24
+ with: str(e.with, 120),
25
+ attendees: Number.isInteger(e.attendees) && e.attendees >= 0 ? e.attendees : null,
26
+ role: ['present', 'decide', 'attend'].includes(e.role) ? e.role : null,
27
+ external: e.external === true,
28
+ series: str(e.series, 200),
29
+ purpose: str(e.purpose, 240),
30
+ prep: str(prep, 240),
31
+ agenda: during.map((x) => str(x && typeof x === 'object' ? x.t : x, 140)).filter(Boolean).slice(0, 6),
32
+ links: (Array.isArray(e.links) ? e.links : []).filter((l) => l && isUrl(l.url)).slice(0, 5).map((l) => ({ label: str(l.label, 60) || 'Link', url: String(l.url).slice(0, 500) })),
33
+ refs: (Array.isArray(e.refs) ? e.refs : []).filter((r) => r && str(r.label, 120)).slice(0, 5).map((r) => ({ label: str(r.label, 120), source: str(r.source, 30), link: isUrl(r.link) ? String(r.link).slice(0, 500) : null })),
34
+ };
35
+ const any = c.with || c.attendees !== null || c.role || c.external || c.series || c.purpose || c.prep || c.agenda.length || c.links.length || c.refs.length;
36
+ return any ? c : null;
37
+ }
38
+
39
+ /** The names in a "with" line ("Ana, Budi and Citra"). Pure. */
40
+ export function peopleOf(c) {
41
+ if (!c || !c.with) return [];
42
+ return String(c.with).split(/\s*(?:,|;|&|\band\b|\bdan\b)\s*/i).map((x) => x.trim()).filter(Boolean).slice(0, 12);
43
+ }
44
+
45
+ const STOP = new Set('the a an and or of to in on for with is are was be it this that we you i at by from as but not'.split(' '));
46
+ const words = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter((w) => w.length > 2 && !STOP.has(w));
47
+
48
+ /** For each agenda item, when it was heard: the time (ms) of the first line that shares at least two of its words, else null.
49
+ * A local word match: the page says "heard", never "done". `lines` are { t, text }. Pure. */
50
+ export function agendaHeard(c, lines) {
51
+ const items = (c && c.agenda) || [];
52
+ const ls = (lines || []).filter((l) => l && typeof l.text === 'string').map((l) => ({ t: typeof l.t === 'number' ? l.t : 0, w: new Set(words(l.text)) }));
53
+ return items.map((a) => {
54
+ const aw = [...new Set(words(a))];
55
+ const need = Math.min(2, aw.length);
56
+ if (!need) return null;
57
+ const hit = ls.find((l) => aw.filter((w) => l.w.has(w)).length >= need);
58
+ return hit ? hit.t : null;
59
+ });
60
+ }
@@ -4,9 +4,9 @@
4
4
  * notesOfInitiative(id) → the note rows linked to an initiative, newest first
5
5
  * noteById(id) → the note's row, or null
6
6
  * notesBlock(e, { ws, repo, initiative }) → the "Meeting notes" block of an event's detail. Take notes makes a note linked
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
7
+ * to the event, starts listening and shows the note in the Notes place
8
+ * (pm/note-dock.js); an event that has a note shows its summary and Open notes,
9
+ * which shows it in the Notes place
10
10
  * noteRows(rows, { onOpen }) → the rows of an initiative's Notes tab; onOpen runs before the note opens
11
11
  * openNote(id) → opens the Notes tab on that note
12
12
  * onNotes(fn) · refreshNotes() → redraw when the list is read
@@ -18,6 +18,7 @@ import { el } from './dom.js';
18
18
  import { button } from './primitives.js';
19
19
  import { pmGet } from './http.js';
20
20
  import { takeNotes, openDock, activeId } from './note-dock.js';
21
+ import { contextOf } from './notes-context.js';
21
22
 
22
23
  const READ_MS = 15000; // the list is read again at most this often while a calendar or an initiative is drawn
23
24
  let rows = null; let readAt = 0; let reading = null; let failed = false;
@@ -54,9 +55,34 @@ export const noteById = (id) => (rows || []).find((n) => n.id === id) || null;
54
55
 
55
56
  /** The meeting link a calendar event gives a note. */
56
57
  export function meetingOf(e) {
58
+ const context = contextOf(e);
57
59
  return { kind: 'event', event_id: /^[A-Za-z0-9_-]{1,40}$/.test(String(e.id || '')) ? String(e.id) : null, src: e.src === 'memory' ? 'memory' : 'brief',
58
- start: e.start, end: e.end || null, title: e.title };
60
+ start: e.start, end: e.end || null, title: e.title, ...(context ? { context } : {}) };
59
61
  }
62
+ export const notesRows = () => rows || [];
63
+
64
+ /** Today's meetings that have not started and have no note yet, soonest first (the Record control's "upcoming" menu).
65
+ * Read from the Agenda: GET /api/proactive/agenda?month=… The answer is kept for UPCOMING_MS. */
66
+ const UPCOMING_MS = 60000;
67
+ let upcoming = null; let upcomingAt = 0; let upcomingReading = null;
68
+ export function upcomingToday({ force = false } = {}) {
69
+ if (!force && upcoming && Date.now() - upcomingAt < UPCOMING_MS) return Promise.resolve(upcoming);
70
+ if (upcomingReading) return upcomingReading;
71
+ const d = new Date(); const pad = (n) => String(n).padStart(2, '0');
72
+ const day = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
73
+ // the Agenda gives the event; today's brief gives the plan for it (purpose, how to be ready, steps, links, related items)
74
+ upcomingReading = Promise.all([pmGet('/api/proactive/agenda?month=' + day.slice(0, 7)), pmGet('/api/proactive/today')]).then(([r, t]) => {
75
+ upcomingReading = null; upcomingAt = Date.now();
76
+ const evs = r && r.days && r.days[day] ? r.days[day].events || [] : [];
77
+ const cal = t && t.ok && t.brief && Array.isArray(t.brief.calendar) ? t.brief.calendar : [];
78
+ const planOf = (e) => cal.find((c) => minuteOf(c.start) === minuteOf(e.start) && normTitle(c.title) === normTitle(e.title));
79
+ const merged = evs.map((e) => { const p = planOf(e); return p ? { ...e, with: e.with || p.with, purpose: p.purpose, prep: typeof p.prep === 'string' ? p.prep : e.prep, steps: p.steps, links: p.links, refs: p.refs, initiative: p.initiative } : e; });
80
+ upcoming = merged.filter((e) => !e.ended && Date.parse(e.start) > Date.now()).sort((a, b) => String(a.start).localeCompare(String(b.start)));
81
+ return upcoming;
82
+ }).catch(() => { upcomingReading = null; return upcoming || []; });
83
+ return upcomingReading;
84
+ }
85
+ export const upcomingWithoutNote = (evs) => (evs || []).filter((e) => notesFor(e).length === 0);
60
86
 
61
87
  /** Open a note in the Notes place (meeting-notes Run 12): the list by initiative, with this note open. */
62
88
  export async function openNote(id) {
@@ -76,7 +102,7 @@ export function notesBlock(e, opts = {}) {
76
102
  if (activeId() === n.id) {
77
103
  return el('div', { class: 'td-day__notes', 'data-notes-block': 'docked', 'data-notes-id': n.id },
78
104
  el('h4', null, 'Meeting notes'),
79
- el('p', { class: 'td-day__note' }, 'This note is open at the bottom right.'),
105
+ el('p', { class: 'td-day__note' }, 'This meeting is being recorded. The note is in Notes.'),
80
106
  el('div', { class: 'td-day__acts' }, button({ label: 'Open the note', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => openDock(n.id) })));
81
107
  }
82
108
  return el('div', { class: 'td-day__notes', 'data-notes-block': 'linked', 'data-notes-id': n.id },
@@ -98,7 +124,7 @@ export function notesBlock(e, opts = {}) {
98
124
  }
99
125
  return el('div', { class: 'td-day__notes', 'data-notes-block': 'none' },
100
126
  el('h4', null, 'Meeting notes'),
101
- 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.' : '.')),
127
+ 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 shows the note in Notes. It is linked to this event' + (opts.initiative ? ' and its initiative.' : '.')),
102
128
  el('div', { class: 'td-day__acts' }, take));
103
129
  }
104
130
 
@@ -356,7 +356,7 @@ export function menu({ anchor, items, ariaLabel } = {}) {
356
356
  ...(it.attrs || {}),
357
357
  type: 'button', role: it.role || 'menuitem', tabindex: '-1', class: cx('pmc-menu__item', it.danger && 'pmc-menu__item--danger'),
358
358
  onClick: (e) => { e.stopPropagation(); close(false); if (it.onSelect) it.onSelect(); },
359
- }, it.iconNode || (it.icon ? icon(it.icon) : null), el('span', null, it.label), it.hint ? el('kbd', { class: 'pmc-menu__hint' }, it.hint) : null);
359
+ }, it.iconNode || (it.icon ? icon(it.icon) : null), el('span', { class: 'pmc-menu__text' }, it.label, it.detail ? el('small', { class: 'pmc-menu__detail' }, it.detail) : null), it.hint ? el('kbd', { class: 'pmc-menu__hint' }, it.hint) : null);
360
360
  btns.push(b); node.appendChild(b);
361
361
  }
362
362
  node.addEventListener('toggle', (e) => { if (e.newState === 'closed') close(false); });