create-pmos 0.59.2 → 0.60.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 (47) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/scripts/delivery-index.js +85 -7
  6. package/payload/scripts/workflow-state.js +3 -3
  7. package/payload/state/discoveries.md +1 -1
  8. package/workspace/REMOTE.md +1 -1
  9. package/workspace/fixtures/initiatives-api/sheet.json +29 -7
  10. package/workspace/guide/index.md +3 -3
  11. package/workspace/guide/initiatives-screen.md +25 -1
  12. package/workspace/guide/layout.md +7 -9
  13. package/workspace/guide/proactive-engine.md +1 -0
  14. package/workspace/guide/today.md +23 -1
  15. package/workspace/lib/calendar-memory.js +257 -0
  16. package/workspace/lib/console-state.js +1 -1
  17. package/workspace/lib/doc-category.js +3 -1
  18. package/workspace/lib/engine/delivery-index.js +85 -7
  19. package/workspace/lib/engine/workflow-state.js +3 -3
  20. package/workspace/lib/initiative-files.js +59 -1
  21. package/workspace/lib/initiative-rows.js +14 -2
  22. package/workspace/lib/mobile-nav.js +1 -1
  23. package/workspace/lib/plan-tree.js +44 -5
  24. package/workspace/lib/proactive-cli.js +16 -1
  25. package/workspace/lib/routes-initiatives.js +165 -9
  26. package/workspace/package-lock.json +1 -1
  27. package/workspace/package.json +1 -1
  28. package/workspace/public/index.html +1 -1
  29. package/workspace/public/pm/components.css +70 -0
  30. package/workspace/public/pm/dest-initiatives.js +7 -1
  31. package/workspace/public/pm/dest-settings.js +1 -0
  32. package/workspace/public/pm/frame-copy.js +14 -2
  33. package/workspace/public/pm/initiative-sheet.js +42 -10
  34. package/workspace/public/pm/initiatives-api.js +15 -0
  35. package/workspace/public/pm/initiatives-copy.js +13 -0
  36. package/workspace/public/pm/initiatives-ui.js +14 -0
  37. package/workspace/public/pm/mobile.js +4 -5
  38. package/workspace/public/pm/registry.js +1 -1
  39. package/workspace/public/pm/tab-term.js +6 -50
  40. package/workspace/public/pm/today-agenda.js +305 -0
  41. package/workspace/public/pm/today-copy.js +54 -2
  42. package/workspace/public/pm/today-day.js +23 -3
  43. package/workspace/public/pm/today-goals.js +7 -1
  44. package/workspace/server.js +71 -63
  45. package/workspace/skills/morning-brief.skill +6 -1
  46. package/workspace/test-files.json +2 -0
  47. package/workspace/test-lists/scan-consistency.json +5 -0
@@ -26,7 +26,7 @@ import { state, setState } from './state.js';
26
26
  import { dayOf } from './breakdown-copy.js';
27
27
  import { COPY, shortDate } from './initiatives-copy.js';
28
28
  import { getSheet, getExport, place, placed, leaveNow, setLifecycle } from './initiatives-api.js';
29
- import { healthSignal, say, confirmSheet, checkBySheet, horizonLabel } from './initiatives-ui.js';
29
+ import { healthSignal, say, confirmSheet, checkBySheet, horizonLabel, rescanRepo } from './initiatives-ui.js';
30
30
 
31
31
  /** The pre-Run-2 detail pane: the fallback, and the home of the legacy gate buttons that "Open gate" reaches. */
32
32
  export function legacyOpen(repo, slug) {
@@ -72,10 +72,12 @@ export function openSheet(repo, slug) {
72
72
  const waitingHost = el('div', { class: 'in-sheet__waiting-host' });
73
73
  const tabsHost = el('div', { class: 'in-sheet__tabs' });
74
74
  const panel = el('div', { class: 'in-sheet__panel', 'data-in-panel': '' });
75
+ // The Breakdown pane mounts into its own host, so the breakdown read from the spec can sit above it (scan-consistency R4).
76
+ const bdHost = el('div', { class: 'in-sheet__bd', 'data-in-bd': '' });
75
77
  const s = sheet({
76
78
  mode: 'auto', title: slug, sub: '', body: [crumb, stepperHost, waitingHost, tabsHost, panel],
77
79
  attrs: { class: 'in-sheet', 'data-in-sheet': 'initiative', 'data-in-id': slug },
78
- onClose: () => { closed = true; unmountBreakdown(panel); unmountJira(panel); if (current === s) current = null; },
80
+ onClose: () => { closed = true; unmountBreakdown(bdHost); unmountJira(panel); if (current === s) current = null; },
79
81
  });
80
82
  current = s;
81
83
 
@@ -117,6 +119,7 @@ export function openSheet(repo, slug) {
117
119
  items.push({ sep: true });
118
120
  items.push({ label: COPY.sheet.copyCli, onSelect: () => copyCli() });
119
121
  items.push({ label: COPY.sheet.exportBreakdown, onSelect: () => exportBreakdown() });
122
+ items.push({ label: COPY.rescan, onSelect: async () => { const a = await rescanRepo(ws); if (a.ok) reload(); } });
120
123
  menu({ anchor, items, ariaLabel: COPY.sheet.menuMore(I.title) });
121
124
  }
122
125
 
@@ -182,7 +185,24 @@ export function openSheet(repo, slug) {
182
185
  const steps = data.stepper || [];
183
186
  stepperHost.replaceChildren(el('ol', { class: 'in-stepper', 'aria-label': COPY.sheet.stages, 'data-in-stepper': '' }, steps.map((st) => el('li', {
184
187
  class: 'in-stepper__step', 'data-state': st.state, 'data-step': st.id, 'aria-current': st.state === 'current' ? 'step' : null,
185
- }, st.state === 'done' ? icon('check') : null, el('span', null, st.label)))));
188
+ title: st.state === 'skipped' && st.owed ? COPY.sheet.skippedStep(st.owed) : null,
189
+ }, st.state === 'done' ? icon('check') : st.state === 'skipped' ? icon('circle-alert') : null, el('span', null, st.label)))));
190
+ }
191
+
192
+ /** The breakdown read from the spec when nothing is planned (read-only; GET /api/initiatives/<id> `derived`). */
193
+ function derivedSection() {
194
+ const d = data.derived;
195
+ if (!d || !Array.isArray(d.items) || !d.items.length) return null;
196
+ const state = COPY.sheet.derivedStates[d.state] || d.state;
197
+ return el('section', { class: 'in-derived', 'data-in-derived': d.state },
198
+ el('h3', { class: 'in-derived__title' }, COPY.sheet.derivedTitle(d.source)),
199
+ el('p', { class: 'in-note' }, COPY.sheet.derivedNote(d.basis)),
200
+ el('div', { class: 'in-sheet__rows', role: 'list' }, d.items.map((it) => row({
201
+ title: (it.ref ? it.ref + ' · ' : '') + it.title, meta: [state], done: d.state === 'done',
202
+ openLabel: COPY.aria.open(it.title),
203
+ onOpen: () => { const src = String(d.source || '').split(',')[0].trim(); if (src) { s.close(); openDoc(slug, src, src.split('/').pop()); } },
204
+ attrs: { 'data-in-derived-item': it.id, 'data-state': d.state },
205
+ }))));
186
206
  }
187
207
 
188
208
  function drawWaiting() {
@@ -224,7 +244,7 @@ export function openSheet(repo, slug) {
224
244
  items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }],
225
245
  onChange: (id) => {
226
246
  if (id === tab) return;
227
- if (tab === 'breakdown') unmountBreakdown(panel);
247
+ if (tab === 'breakdown') unmountBreakdown(bdHost);
228
248
  if (tab === 'jira') unmountJira(panel);
229
249
  tab = id;
230
250
  drawTabs(); drawPanel();
@@ -236,15 +256,27 @@ export function openSheet(repo, slug) {
236
256
 
237
257
  function drawPanel() {
238
258
  if (!data) return;
239
- if (tab === 'breakdown') { mountBreakdown(panel, { id: tabId, ws, slug }, { embedded: true }); return; }
259
+ if (tab === 'breakdown') {
260
+ const derived = derivedSection();
261
+ panel.replaceChildren(...(derived ? [derived, bdHost] : [bdHost]));
262
+ mountBreakdown(bdHost, { id: tabId, ws, slug }, { embedded: true });
263
+ return;
264
+ }
240
265
  if (tab === 'jira') { mountJira(panel, { ws, slug }); return; }
241
266
  if (tab === 'files') {
242
267
  const files = data.files || [];
243
- panel.replaceChildren(files.length ? el('div', { class: 'in-sheet__rows', role: 'list' }, files.map((f) => row({
244
- title: f.label, meta: [f.path], openLabel: COPY.aria.open(f.label),
245
- onOpen: () => { s.close(); openDoc(slug, f.path, f.label); },
246
- attrs: { 'data-in-file': f.kind }, openAttrs: { 'data-in-file-open': f.kind },
247
- }))) : empty({ text: COPY.sheet.noFiles }));
268
+ // Grouped by where the tie came from (scan-consistency R3): the record's own paths, the documents the library's
269
+ // index links to it, and the files git changed for it. A `note` row is a fact about the list, not a file.
270
+ const order = ['record', 'knowledge-graph', 'git'];
271
+ const groups = order.map((src) => [src, files.filter((f) => (f.source || 'record') === src)]).filter(([, list]) => list.length);
272
+ const fileRow = (f) => (f.kind === 'note' ? el('p', { class: 'in-note', 'data-in-file-note': '' }, f.label) : row({
273
+ title: f.label, meta: [f.path, f.commits ? COPY.sheet.commits(f.commits) : null, f.shared ? COPY.sheet.shared : null], openLabel: COPY.aria.open(f.label),
274
+ onOpen: () => { s.close(); openDoc(slug, f.path, f.label, null, f.source === 'git' ? { readonly: true } : undefined); },
275
+ attrs: { 'data-in-file': f.kind, 'data-in-file-source': f.source || 'record' }, openAttrs: { 'data-in-file-open': f.kind },
276
+ }));
277
+ panel.replaceChildren(files.length ? el('div', { class: 'in-files' }, groups.map(([src, list]) => el('section', { class: 'in-files__group', 'data-in-files': src },
278
+ el('h3', { class: 'in-files__title' }, COPY.sheet.fileSource[src] || src),
279
+ el('div', { class: 'in-sheet__rows', role: 'list' }, list.map(fileRow))))) : empty({ text: COPY.sheet.noFiles }));
248
280
  return;
249
281
  }
250
282
  const hist = data.history || [];
@@ -10,6 +10,7 @@
10
10
  * GET /api/initiatives/<id>/export?ws= the breakdown as text
11
11
  * POST /api/placements { ws, events: [placed…] } every move, reorder and check-by
12
12
  * POST /api/initiatives { ws, title, lane, anchor, intent?, from? } the New-initiative form and a triage card (one body, no events)
13
+ * POST /api/initiatives/rescan { ws } the Re-scan press: read the repository again now (nothing is written)
13
14
  *
14
15
  * Every GET of /api/initiatives answers 202 { ok: false, pending: true } until the repository's first derivation lands (2C,
15
16
  * about 260 ms a record); a read waits and asks again, so a caller sees the answer or a real error, never the 202.
@@ -47,6 +48,20 @@ export const adopt = (ws, proposal) => pmPost('/api/placements', { ws, events: [
47
48
  export const drop = (ws, proposal) => pmPost('/api/placements', { ws, events: [{ ev: 'dropped', proposal }] });
48
49
 
49
50
  /** A new initiative: from the form, or from a triaged capture (`from` is the capture's id). It lands in Later. */
51
+ /** Read the repository again now. Answers when the new derivation has landed (the records part of the sig stops reading
52
+ * `~stale`), or { ok: true, slow: true } when it is still running after about three minutes. */
53
+ export async function rescan(ws) {
54
+ const a = await pmPost('/api/initiatives/rescan', { ws });
55
+ if (!a || !a.ok) return a || { ok: false, error: 'no answer' };
56
+ for (let k = 0; k < 120; k++) {
57
+ await sleep(1500);
58
+ const r = await pmGet('/api/initiatives?ws=' + enc(ws) + '&view=roadmap');
59
+ const rec = r && r.sig ? String(r.sig.records || '') : '';
60
+ if (r && r.ok && rec && !rec.endsWith('~stale')) return { ok: true };
61
+ }
62
+ return { ok: true, slow: true };
63
+ }
64
+
50
65
  export const createInitiative = (ws, fields) => pmPost('/api/initiatives', { ws, ...fields });
51
66
 
52
67
  export const setLifecycle = (ws, id, state, reason) => pmPost('/api/lifecycle', { ws, initiative_id: id, state, reason: reason || '' });
@@ -28,6 +28,12 @@ export const COPY = {
28
28
  roadmap: 'Roadmap',
29
29
  list: 'List',
30
30
  newInitiative: 'New initiative',
31
+ rescan: 'Re-scan the repository',
32
+ rescanning: 'Re-scanning…',
33
+ rescanHint: 'Read stage, files and breakdown again now',
34
+ rescanDone: (t) => `Read again at ${t}`,
35
+ rescanFailed: (why) => `Could not re-scan: ${why}`,
36
+ rescanSlow: 'The re-scan is still running. The view updates when it ends.',
31
37
  reading: 'Reading…',
32
38
  readFailed: (why) => `Could not read the initiatives: ${why}`,
33
39
  noRepo: 'Add a repository to see its initiatives.',
@@ -127,6 +133,13 @@ export const COPY = {
127
133
  openGate: 'Open gate',
128
134
  handToAgent: 'Hand to agent',
129
135
  noFiles: 'No files yet.',
136
+ fileSource: { record: 'Named by the record', 'knowledge-graph': 'Linked documents', git: 'Changed for it in git' },
137
+ commits: (n) => (n === 1 ? '1 commit' : `${n} commits`),
138
+ shared: 'shared',
139
+ skippedStep: (noun) => `Skipped: ${noun} is still open`,
140
+ derivedTitle: (src) => `From the spec · ${src}`,
141
+ derivedNote: (basis) => `Read from the spec, because the Breakdown has no items yet. Each item shows the state of the whole initiative: ${basis}.`,
142
+ derivedStates: { todo: 'Todo', review: 'Built', done: 'Done' },
130
143
  noHistory: 'No history yet.',
131
144
  reading: 'Reading…',
132
145
  readFailed: (why) => `Could not read the initiative: ${why}`,
@@ -6,12 +6,14 @@
6
6
  * confirmSheet({…}) a sheet that asks for one press (a PM-only act): an optional choice, a reason, Cancel, Confirm
7
7
  * checkBySheet({…}) a sheet with a date and Save or Clear
8
8
  * say(out, ok) a toast for the answer of a write: the success text, or the server's refusal
9
+ * rescanRepo(ws) read the repository again now, with a toast at the start and at the end (scan-consistency R6)
9
10
  * No native dialog is used anywhere here. Every control is a primitive (primitives.js).
10
11
  */
11
12
 
12
13
  import { el } from './dom.js';
13
14
  import { button, field, signal, sheet, tabs, toast } from './primitives.js';
14
15
  import { COPY } from './initiatives-copy.js';
16
+ import { rescan } from './initiatives-api.js';
15
17
 
16
18
  export const horizonLabel = (h) => COPY.horizons[h] || h;
17
19
  export const progressText = (row) => (row && row.progress && row.progress.label) || '';
@@ -24,6 +26,18 @@ export function healthSignal(health) {
24
26
  return signal({ text: top.text || COPY.healthUnknown, tone, title: top.source || null, attrs: { 'data-health': health.level } });
25
27
  }
26
28
 
29
+ /** Re-scan: read the repository again now. A toast says it started and another says how it ended. Answers the rescan
30
+ * answer; the caller reloads what it shows when `ok`. */
31
+ export async function rescanRepo(ws) {
32
+ toast({ text: COPY.rescanning, timeout: 4000 });
33
+ const a = await rescan(ws);
34
+ const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
35
+ if (!a || !a.ok) toast({ text: COPY.rescanFailed((a && a.error) || 'no answer'), tone: 'error', timeout: 8000 });
36
+ else toast({ text: a.slow ? COPY.rescanSlow : COPY.rescanDone(t), timeout: 6000 });
37
+ try { if (a && a.ok) window.dispatchEvent(new CustomEvent('pmos:initiatives-changed')); } catch { /* no window */ }
38
+ return a || { ok: false };
39
+ }
40
+
27
41
  /** A toast for the answer of a write. A refusal shows the server's own words. */
28
42
  export function say(out, okText) {
29
43
  if (out && out.ok) { if (okText) toast({ text: okText, timeout: 4000 }); return true; }
@@ -505,11 +505,10 @@ export function mountMobile(context) {
505
505
  if (theme) theme.addEventListener('click', toggleTheme);
506
506
  document.addEventListener('click', onChromeClick);
507
507
 
508
- /* `resize` only. A visual-viewport SCROLL moves the page under the keyboard and changes no box,
509
- * so there is nothing to re-fit — but it fires on every frame of a touch scroll, and each one used
510
- * to become a synthetic window `resize` that every resize listener on the page answered. That was
511
- * the terminal stuttering under a finger on a phone. */
512
- if (window.visualViewport) window.visualViewport.addEventListener('resize', refit);
508
+ if (window.visualViewport) {
509
+ window.visualViewport.addEventListener('resize', refit);
510
+ window.visualViewport.addEventListener('scroll', refit);
511
+ }
513
512
  window.addEventListener('orientationchange', () => setTimeout(refit, 250));
514
513
 
515
514
  // Crossing the breakpoint (rotate a tablet, drag a window narrow) has to re-decide whether the
@@ -44,7 +44,7 @@ import { pmGet } from './http.js';
44
44
  import { empty, ensureComponentsCss } from './primitives.js';
45
45
 
46
46
  export const DESTINATIONS = Object.freeze([
47
- Object.freeze({ id: 'today', label: 'Today', key: 'g t', module: 'dest-today.js', run: 3 }),
47
+ Object.freeze({ id: 'today', label: 'Agenda', key: 'g t', module: 'dest-today.js', run: 3 }),
48
48
  Object.freeze({ id: 'initiatives', label: 'Initiatives', key: 'g i', module: 'dest-initiatives.js', run: 2 }),
49
49
  Object.freeze({ id: 'board', label: 'Board', key: 'g b', module: 'dest-board.js', run: 4 }),
50
50
  Object.freeze({ id: 'reports', label: 'Reports', key: 'g r', module: 'dest-reports.js', run: 5 }),
@@ -678,25 +678,7 @@ function syncSize(rec) {
678
678
  if (!rec.sessionId || rec.attachedId !== rec.sessionId) return;
679
679
  const cols = rec.term.cols, rows = rec.term.rows;
680
680
  if (!cols || !rows) return;
681
- if (rec.sentCols === cols && rec.sentRows === rows) { clearTimeout(rec.sizeTimer); rec.sizeTimer = null; return; }
682
- /* The FIRST statement on an attach goes out at once: until it does, the shell is drawing into the
683
- * 80×24 it was spawned with. A CHANGE after that waits for the grid to settle. A phone's soft
684
- * keyboard slides in over about a quarter of a second and the pane is re-fitted on every frame of
685
- * it; each `resize` the PTY gets makes a full-screen agent CLI repaint everything it shows, so
686
- * stating every intermediate grid buried the session under a dozen redraws of screens that were
687
- * gone before they arrived. The local fit is not delayed — only what the shell is told. */
688
- if (rec.sentCols == null) { sendSize(rec); return; }
689
- clearTimeout(rec.sizeTimer);
690
- rec.sizeTimer = setTimeout(() => { rec.sizeTimer = null; sendSize(rec); }, RESIZE_SETTLE_MS);
691
- }
692
-
693
- /** How long the grid must hold still before a change is stated to the PTY. See syncSize. */
694
- const RESIZE_SETTLE_MS = 120;
695
-
696
- function sendSize(rec) {
697
- if (!rec.term || !rec.sessionId || rec.attachedId !== rec.sessionId) return;
698
- const cols = rec.term.cols, rows = rec.term.rows;
699
- if (!cols || !rows || (rec.sentCols === cols && rec.sentRows === rows)) return;
681
+ if (rec.sentCols === cols && rec.sentRows === rows) return;
700
682
  if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
701
683
  }
702
684
 
@@ -712,40 +694,19 @@ function note(rec, line, force) {
712
694
  * The single entry point. Coalesced into one animation frame so the six triggers below cost one
713
695
  * measure per frame rather than six. A pane that is not in the document, or has no box yet, is
714
696
  * skipped — fitting a zero-sized element is how a terminal ends up 1×1 and stays there.
715
- *
716
- * A pane whose box has not moved since it was last fitted is NOT re-measured. Every state change
717
- * in the app lands here (the `subscribe` below), and a fit is a getComputedStyle round-trip on the
718
- * terminal's parent per pane — on a phone, that was a forced style pass on every render for a grid
719
- * that could not have changed. The grid depends on the box and on the cell size; the box is the key
720
- * here, and the one event that moves the cell size without moving the box (the web font arriving)
721
- * passes `force`.
722
697
  */
723
698
  let refitQueued = false;
724
- let refitForce = false;
725
699
 
726
- export function refitVisible(force) {
727
- if (force === true) refitForce = true;
700
+ export function refitVisible() {
728
701
  if (refitQueued) return;
729
702
  refitQueued = true;
730
703
  const run = () => {
731
704
  refitQueued = false;
732
- const forced = refitForce;
733
- refitForce = false;
734
705
  for (const rec of panes.values()) {
735
706
  if (rec.kind !== 'real' || !rec.fit || !rec.host) continue;
736
707
  if (!rec.host.isConnected) continue;
737
- const w = rec.host.offsetWidth, h = rec.host.offsetHeight;
738
- if (!w || !h) continue;
739
- if (!forced && rec.fitW === w && rec.fitH === h) { syncSize(rec); continue; }
740
- try {
741
- // Measured first, so a pane whose renderer cannot measure yet is retried next time rather
742
- // than remembered as fitted at a grid it never reached.
743
- const dims = rec.fit.proposeDimensions();
744
- if (dims && dims.cols > 0 && dims.rows > 0) {
745
- if (dims.cols !== rec.term.cols || dims.rows !== rec.term.rows) rec.fit.fit();
746
- rec.fitW = w; rec.fitH = h;
747
- }
748
- } catch { /* a terminal mid-teardown */ }
708
+ if (!rec.host.offsetWidth || !rec.host.offsetHeight) continue;
709
+ try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
749
710
  // …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
750
711
  // grid MOVES, so a pane that was measured before it had a session would otherwise never tell
751
712
  // the shell how wide it is. See the `attached` handler.
@@ -763,8 +724,7 @@ if (typeof window !== 'undefined') {
763
724
  subscribe(() => refitVisible());
764
725
  window.addEventListener('resize', () => refitVisible());
765
726
  document.addEventListener('visibilitychange', () => { if (!document.hidden) refitVisible(); });
766
- // The face changes the CELL, not the box, so this one re-measures every pane regardless.
767
- if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => refitVisible(true)).catch(() => {});
727
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => refitVisible()).catch(() => {});
768
728
  }
769
729
 
770
730
  /* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
@@ -971,7 +931,6 @@ export function disposeTerm(tab) {
971
931
  * pane for the id and detaches from the orphan instead. */
972
932
  if (rec.kind !== 'real') return;
973
933
  if (rec.ro) { try { rec.ro.disconnect(); } catch { /* ignore */ } }
974
- if (rec.sizeTimer) { clearTimeout(rec.sizeTimer); rec.sizeTimer = null; }
975
934
  if (rec.sessionId) {
976
935
  send({ t: 'detach', id: rec.sessionId });
977
936
  SOCK.attached.delete(rec.sessionId);
@@ -1099,9 +1058,6 @@ export function termProbe() {
1099
1058
  */
1100
1059
  export function __handleForTest(m) { return handle(m); }
1101
1060
 
1102
- /* `fit` is the name mobile.js asks for when the soft keyboard or a rotation moves the box. Without it
1103
- * that module fell back to dispatching a synthetic window `resize`, which every resize listener on
1104
- * the page answered — not only this one. */
1105
- if (typeof window !== 'undefined') window.pmTerm = { refitVisible, fit: () => refitVisible(), termProbe, panes, requestSpawn };
1061
+ if (typeof window !== 'undefined') window.pmTerm = { refitVisible, termProbe, panes, requestSpawn };
1106
1062
 
1107
1063
  export default mountTerm;
@@ -0,0 +1,305 @@
1
+ /* pm/today-agenda.js — the Agenda month in the left column of Today (initiative today-goals, PRD G25 to G27).
2
+ *
3
+ * agendaPanel(ctx, h) → the panel: the month on top, the agenda of the picked day or week under it; null when the
4
+ * server has no agenda route (an older workspace), so the day's calendar list shows instead
5
+ * meetingDetail(ctx, id) → the right column for a remembered meeting: what it means, before it, the last time
6
+ * memoryBlock(id) → the series part of that detail, for a meeting of today drawn from the brief
7
+ * litSeries(sel) → the series key of the selected meeting, so the month marks the days it happens
8
+ * refreshAgenda() · agendaSig() · onAgenda(fn)
9
+ *
10
+ * One read feeds it: GET /api/proactive/agenda?month=YYYY-MM. The server derives every answer (how full, the key
11
+ * meeting, readiness, no notes); this file only draws them. A day cell is a button: it picks that day. A week number
12
+ * picks the week. The lens switch says which answer the cells show; the agenda under the month always shows them all.
13
+ * Nothing here writes: the calendar memory changes only through the morning-brief session's brief.
14
+ */
15
+
16
+ import { el } from './dom.js';
17
+ import { button, iconButton, badge, tabs } from './primitives.js';
18
+ import { pmGet } from './http.js';
19
+ import { COPY } from './today-copy.js';
20
+
21
+ const A = COPY.day.ag;
22
+ const DAY0 = 9 * 60;
23
+ const SPAN = 9 * 60;
24
+
25
+ const cache = new Map(); // month -> { data, error, old }
26
+ const ag = { month: null, pick: null, lens: 'overview', showRoutine: false };
27
+ let redraw = () => {};
28
+ let inflight = new Set();
29
+
30
+ export function onAgenda(fn) { redraw = fn; }
31
+
32
+ const pad = (n) => String(n).padStart(2, '0');
33
+ const ymOf = (date) => date.slice(0, 7);
34
+ const shiftMonth = (ym, n) => { const [y, m] = ym.split('-').map(Number); const d = new Date(y, m - 1 + n, 1); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; };
35
+ const dateOf = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
36
+ const label = (s, opts) => { try { return dateOf(s).toLocaleDateString(undefined, opts); } catch { return s; } };
37
+ const dayLabel = (s) => label(s, { weekday: 'short', day: 'numeric', month: 'short' });
38
+ /** ISO 8601 week number of a YYYY-MM-DD date. */
39
+ function isoWeek(s) {
40
+ const d = dateOf(s);
41
+ const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
42
+ const wd = t.getUTCDay() || 7;
43
+ t.setUTCDate(t.getUTCDate() + 4 - wd);
44
+ const y0 = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
45
+ return Math.ceil(((t - y0) / 86400000 + 1) / 7);
46
+ }
47
+
48
+ async function loadMonth(month) {
49
+ const key = month || 'now';
50
+ if (inflight.has(key)) return;
51
+ inflight.add(key);
52
+ const out = await pmGet('/api/proactive/agenda' + (month ? '?month=' + encodeURIComponent(month) : ''));
53
+ inflight.delete(key);
54
+ const m = out && out.ok ? out.month : month;
55
+ const prev = cache.get(m);
56
+ const next = out && out.ok ? { data: out, error: null, old: false }
57
+ : { data: prev ? prev.data : null, error: (out && out.error) || A.unread, old: !!(out && out.status === 404) };
58
+ if (!ag.month && out && out.ok) ag.month = out.month;
59
+ if (!month && !(out && out.ok)) cache.set('now', next);
60
+ if (m) cache.set(m, next);
61
+ // `now` moves every read: compare it to the minute, so a read that changes nothing draws nothing
62
+ if (!prev || sigOf(prev) !== sigOf(next)) redraw();
63
+ }
64
+
65
+ const sigOf = (c) => JSON.stringify({ ...c, data: c.data ? { ...c.data, now: String(c.data.now).slice(0, 16) } : null });
66
+
67
+ /** Read the shown month again (the day read calls this, so the month follows the brief and the clock). */
68
+ export function refreshAgenda() { loadMonth(ag.month); }
69
+
70
+ function current() {
71
+ const c = (ag.month && cache.get(ag.month)) || cache.get('now') || null;
72
+ if (!c) { loadMonth(ag.month); return null; }
73
+ return c;
74
+ }
75
+
76
+ export function agendaSig() {
77
+ const c = ag.month ? cache.get(ag.month) : cache.get('now');
78
+ return JSON.stringify([ag, c ? [c.error, c.data && c.data.now ? String(c.data.now).slice(0, 16) : null, c.data && Object.keys(c.data.days || {}).length, c.data && c.data.memory] : null])
79
+ + (c && c.data ? JSON.stringify(c.data.days).length : 0);
80
+ }
81
+
82
+ /* ── finding a meeting across the months read ─────────────────────────────────────────────────────────────────── */
83
+
84
+ function findEvent(id) {
85
+ for (const c of cache.values()) {
86
+ if (!c.data) continue;
87
+ for (const d of Object.values(c.data.days)) {
88
+ const e = d.events.find((x) => x.id === id);
89
+ if (e) return { e, day: d, data: c.data };
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /** The series key of the selected meeting (a remembered one, or today's from the brief), or null. */
96
+ export function litSeries(sel) {
97
+ if (!sel || (sel.type !== 'meeting' && sel.type !== 'event')) return null;
98
+ const f = findEvent(sel.id);
99
+ return f ? { key: f.e.key, title: f.e.title } : null;
100
+ }
101
+
102
+ /* ── the month ────────────────────────────────────────────────────────────────────────────────────────────────── */
103
+
104
+ function strip(day, lens) {
105
+ const s = el('div', { class: 'td-ag__strip', 'aria-hidden': 'true' });
106
+ const put = (from, mins, cls) => {
107
+ const a = Math.max(0, from - DAY0); const b = Math.min(SPAN, from - DAY0 + mins);
108
+ if (b <= a) return;
109
+ const n = el('span', { class: cls });
110
+ n.style.setProperty('--td-ag-l', (a / SPAN * 100).toFixed(2) + '%');
111
+ n.style.setProperty('--td-ag-w', ((b - a) / SPAN * 100).toFixed(2) + '%');
112
+ s.append(n);
113
+ };
114
+ if (lens === 'free') for (const [a, b] of day.gaps) put(a, b - a, 'td-ag__blk td-ag__blk--free');
115
+ else for (const e of day.events) { const [h, m] = e.time.split(':').map(Number); put(h * 60 + m, e.minutes, 'td-ag__blk' + (e.imp ? ' td-ag__blk--imp' : '')); }
116
+ return s;
117
+ }
118
+
119
+ function readyBadge(day, withWord) {
120
+ if (!day.need) return null;
121
+ return el('span', { class: 'td-ag__ready', 'data-ag-ready': day.ready || 'ok', title: A.readyWord[day.ready || 'ok'] },
122
+ A.readyBadge(day.done, day.need), withWord ? ' ' + A.readyWord[day.ready || 'ok'] : null);
123
+ }
124
+
125
+ function keyLine(day) {
126
+ if (!day.key) return null;
127
+ const e = day.events.find((x) => x.id === day.key);
128
+ return e ? el('span', { class: 'td-ag__key' }, A.star + ' ' + e.title) : null;
129
+ }
130
+
131
+ function cell(data, d, lit) {
132
+ const day = data.days[d];
133
+ const lens = ag.lens;
134
+ const kids = [el('span', { class: 'td-ag__num' }, String(Number(d.slice(8))))];
135
+ if (day.events.length && (lens === 'overview' || lens === 'full' || lens === 'free')) kids.push(strip(day, lens));
136
+ if (lens === 'overview') {
137
+ kids.push(keyLine(day));
138
+ if (day.ready === 'risk' || day.ready === 'warn' || (day.ready === 'ok' && !day.past)) kids.push(readyBadge(day));
139
+ if (day.past && day.no_notes) kids.push(el('span', { class: 'td-ag__nonotes' }, A.noNotes(day.no_notes)));
140
+ } else if (lens === 'full') {
141
+ if (day.busy) kids.push(el('span', { class: 'td-ag__meta' }, A.busy(day.busy)));
142
+ } else if (lens === 'key') {
143
+ kids.push(keyLine(day));
144
+ } else if (lens === 'ready') {
145
+ kids.push(readyBadge(day, false));
146
+ if (day.past && day.no_notes) kids.push(el('span', { class: 'td-ag__nonotes' }, A.noNotes(day.no_notes)));
147
+ } else if (lens === 'free' && !day.weekend) {
148
+ kids.push(el('span', { class: 'td-ag__meta' }, day.gaps.length ? A.freeBlocks(day.gaps.length) : A.noFree));
149
+ }
150
+ const picked = ag.pick && ag.pick.type === 'day' && ag.pick.date === d;
151
+ return button({ label: el('span', { class: 'td-ag__in' }, ...kids.filter(Boolean)), variant: 'ghost', onClick: () => pickDay(d),
152
+ attrs: { class: 'td-ag__cell', 'data-ag-day': d, 'data-out': day.in_month ? null : 'true', 'data-past': day.past ? 'true' : null, 'data-today': day.today ? 'true' : null,
153
+ 'data-weekend': day.weekend ? 'true' : null, 'data-lit': lit && lit.has(d) ? 'true' : null, 'aria-pressed': picked ? 'true' : 'false', title: A.dayHint(dayLabel(d), day.key ? (day.events.find((e) => e.id === day.key) || {}).title : null) } });
154
+ }
155
+
156
+ function pickDay(d) { ag.pick = { type: 'day', date: d }; ag.showRoutine = false; redraw(); }
157
+ function pickWeek(i) { ag.pick = { type: 'week', i }; ag.showRoutine = false; redraw(); }
158
+ function goMonth(n, data) {
159
+ ag.month = n === 0 ? ymOf(data.today) : shiftMonth(ag.month || data.month, n);
160
+ ag.pick = null;
161
+ if (!cache.has(ag.month)) loadMonth(ag.month);
162
+ redraw();
163
+ }
164
+
165
+ function month(data, lit) {
166
+ const head = el('div', { class: 'td-ag__mhead' },
167
+ iconButton({ icon: 'chevron-left', ariaLabel: A.prev, size: 'sm', attrs: { 'data-ag': 'prev' }, onClick: () => goMonth(-1, data) }),
168
+ el('b', { class: 'td-ag__mlabel', 'data-ag': 'month' }, label(data.month + '-01', { month: 'long', year: 'numeric' })),
169
+ iconButton({ icon: 'chevron-right', ariaLabel: A.later, size: 'sm', attrs: { 'data-ag': 'later' }, onClick: () => goMonth(1, data) }),
170
+ ymOf(data.today) !== data.month ? button({ label: A.thisMonth, size: 'sm', variant: 'ghost', attrs: { 'data-ag': 'this-month' }, onClick: () => goMonth(0, data) }) : null);
171
+ const lens = tabs({ variant: 'segmented', role: 'radiogroup', ariaLabel: A.title, value: ag.lens, attrs: { 'data-ag': 'lens', class: 'td-ag__lens' },
172
+ items: Object.keys(A.lens).map((k) => ({ id: k, label: A.lens[k] })), onChange: (v) => { ag.lens = v; redraw(); } });
173
+ const wd = el('div', { class: 'td-ag__wk td-ag__wk--head', 'aria-hidden': 'true' }, el('span', null, ''),
174
+ ...data.weeks[0].map((d) => el('span', { class: 'td-ag__wd' }, label(d, { weekday: 'short' }))));
175
+ const rows = data.weeks.map((w, i) => {
176
+ const n = isoWeek(w[0]);
177
+ const picked = ag.pick && ag.pick.type === 'week' && ag.pick.i === i;
178
+ return el('div', { class: 'td-ag__wk', role: 'group', 'aria-label': A.weekOf(n) },
179
+ button({ label: A.weekNo(n), variant: 'ghost', size: 'sm', onClick: () => pickWeek(i), attrs: { class: 'td-ag__wno', 'data-ag-week': String(n), 'aria-pressed': picked ? 'true' : 'false', title: A.weekHint(n) } }),
180
+ ...w.map((d) => cell(data, d, lit)));
181
+ });
182
+ return [head, lens, el('p', { class: 'td-day__note', 'data-ag': 'lens-help' }, A.lensHelp[ag.lens]),
183
+ el('div', { class: 'td-ag__grid', 'data-ag': 'grid', 'data-lens': ag.lens }, wd, ...rows)];
184
+ }
185
+
186
+ /* ── the agenda under the month ───────────────────────────────────────────────────────────────────────────────── */
187
+
188
+ function tiles(day) {
189
+ const key = day.key ? day.events.find((e) => e.id === day.key) : null;
190
+ const t = (k, v, attrs) => el('div', { class: 'td-ag__tile', ...(attrs || {}) }, el('span', { class: 'td-ag__tk' }, A.tiles[k]), el('b', null, v));
191
+ return el('div', { class: 'td-ag__tiles', 'data-ag': 'tiles' },
192
+ t('busy', day.busy ? A.busy(day.busy) : A.noMeetings),
193
+ t('key', key ? key.title : A.noKey),
194
+ t('ready', day.need ? A.readyBadge(day.done, day.need) + ' · ' + A.readyWord[day.ready || 'ok'] : A.noPrep, day.need ? { 'data-ag-ready': day.ready || 'ok' } : null));
195
+ }
196
+
197
+ function eventRow(h, e) {
198
+ const type = e.src === 'brief' ? 'event' : 'meeting';
199
+ const chips = el('span', { class: 'td-day__chips' },
200
+ e.imp ? badge({ text: A.star, tone: 'outline', attrs: { title: A.means } }) : null,
201
+ e.prep ? el('span', { class: 'td-ag__ready', 'data-ag-ready': e.ready || 'ok' }, A.readyBadge(e.prep_done || 0, e.prep.length)) : null,
202
+ e.ended && !e.notes && (e.imp || e.kind === 'one') ? el('span', { class: 'td-ag__nonotes' }, A.noNotes(1)) : null);
203
+ return h.rowBtn(type, e.id, { lead: e.time, title: e.title, meta: A.kindWord[e.kind] + (e.attendees ? ' · ' + A.people(e.attendees) : ''), trail: chips },
204
+ { 'data-past': e.ended ? 'true' : null, 'data-ag-event': e.id });
205
+ }
206
+
207
+ function rowsOf(h, evs) {
208
+ const main = evs.filter((e) => !e.routine);
209
+ const rout = evs.filter((e) => e.routine);
210
+ return [
211
+ main.length ? el('ul', { class: 'td-day__rows', 'data-day-list': 'agenda' }, ...main.map((e) => eventRow(h, e))) : null,
212
+ rout.length ? button({ label: ag.showRoutine ? A.hideRoutine : A.routine(rout.length), variant: 'ghost', size: 'sm',
213
+ attrs: { 'data-ag': 'routine', 'aria-expanded': ag.showRoutine ? 'true' : 'false' }, onClick: () => { ag.showRoutine = !ag.showRoutine; redraw(); } }) : null,
214
+ rout.length && ag.showRoutine ? el('ul', { class: 'td-day__rows', 'data-day-list': 'routine' }, ...rout.map((e) => eventRow(h, e))) : null,
215
+ ];
216
+ }
217
+
218
+ function agendaOf(data, h) {
219
+ const pick = ag.pick || (data.days[data.today] ? { type: 'day', date: data.today } : { type: 'day', date: data.month + '-01' });
220
+ if (pick.type === 'week') {
221
+ const w = data.weeks[pick.i] || data.weeks[0];
222
+ const all = w.flatMap((d) => data.days[d].events);
223
+ const days = w.filter((d) => data.days[d].events.some((e) => !e.routine));
224
+ const rout = all.filter((e) => e.routine);
225
+ return el('div', { class: 'td-ag__agenda', 'data-ag': 'agenda', 'data-ag-pick': 'week:' + isoWeek(w[0]) },
226
+ el('h3', { class: 'td-ag__ah' }, A.weekOf(isoWeek(w[0])), el('span', { class: 'td-day__dim' }, ' · ' + dayLabel(w[0]) + ' – ' + dayLabel(w[6]))),
227
+ days.length ? days.map((d) => el('div', { class: 'td-ag__wday' }, el('h4', null, dayLabel(d), data.days[d].busy ? el('span', { class: 'td-day__dim' }, ' · ' + A.busy(data.days[d].busy)) : null, readyBadge(data.days[d])),
228
+ el('ul', { class: 'td-day__rows' }, ...data.days[d].events.filter((e) => !e.routine).map((e) => eventRow(h, e))))) : el('p', { class: 'td-day__note' }, A.noMeetings),
229
+ rout.length ? button({ label: ag.showRoutine ? A.hideRoutine : A.routine(rout.length), variant: 'ghost', size: 'sm',
230
+ attrs: { 'data-ag': 'routine', 'aria-expanded': ag.showRoutine ? 'true' : 'false' }, onClick: () => { ag.showRoutine = !ag.showRoutine; redraw(); } }) : null,
231
+ rout.length && ag.showRoutine ? el('ul', { class: 'td-day__rows', 'data-day-list': 'routine' }, ...rout.map((e) => eventRow(h, e))) : null);
232
+ }
233
+ const day = data.days[pick.date];
234
+ if (!day) return null;
235
+ // today keeps the day's calendar from the brief (the line at the current time, the goal ties, the prep chips)
236
+ const todayList = day.today && h.todayRows ? h.todayRows() : null;
237
+ return el('div', { class: 'td-ag__agenda', 'data-ag': 'agenda', 'data-ag-pick': 'day:' + pick.date },
238
+ el('h3', { class: 'td-ag__ah' }, day.today ? A.today + ' · ' + dayLabel(pick.date) : dayLabel(pick.date)),
239
+ tiles(day),
240
+ todayList || (day.events.length ? rowsOf(h, day.events) : el('p', { class: 'td-day__note' }, A.noMeetings)));
241
+ }
242
+
243
+ /** The panel. `h` lends the day section's helpers: { panel, rowBtn(type, id, parts, mods), todayRows(), sel }. */
244
+ export function agendaPanel(ctx, h) {
245
+ const c = current();
246
+ if (!c) return h.panel('agenda', null, el('p', { class: 'td-day__note' }, A.loading));
247
+ if (!c.data) return c.old ? null : h.panel('agenda', null, el('p', { class: 'td-day__note', role: 'status' }, c.error || A.unread));
248
+ const data = c.data;
249
+ const lit = litSeries(h.sel);
250
+ const litDays = lit && data.series[lit.key] ? new Set(data.series[lit.key].dates) : null;
251
+ const empty = !data.memory || !data.memory.events;
252
+ return h.panel('agenda', null,
253
+ ...month(data, litDays),
254
+ litDays ? el('p', { class: 'td-day__note', 'data-ag': 'lit' }, A.lit(lit.title, [...litDays].filter((d) => data.days[d]).length)) : null,
255
+ empty ? el('p', { class: 'td-day__note', 'data-ag': 'empty' }, A.empty) : null,
256
+ agendaOf(data, h));
257
+ }
258
+
259
+ /* ── the right column ─────────────────────────────────────────────────────────────────────────────────────────── */
260
+
261
+ function whyLines(e) {
262
+ const out = [];
263
+ if (e.role === 'present') out.push(A.why.present);
264
+ if (e.role === 'decide') out.push(A.why.decide);
265
+ if (e.external) out.push(A.why.external);
266
+ if (e.exec) out.push(A.why.exec);
267
+ if (e.attendees && e.attendees >= 8) out.push(A.why.many(e.attendees));
268
+ if (e.routine) out.push(A.why.routine);
269
+ return out;
270
+ }
271
+
272
+ /** The series of a meeting: how often it met, what is to come, and the last time, folded. Null when unknown. */
273
+ export function memoryBlock(id) {
274
+ const f = findEvent(id);
275
+ if (!f) return null;
276
+ const s = f.data.series[f.e.key];
277
+ if (!s) return null;
278
+ const last = s.last && s.last.date !== f.day.date ? s.last : null;
279
+ return el('div', { class: 'td-day__notes', 'data-ag': 'memory' },
280
+ el('h4', null, A.means),
281
+ el('p', null, A.kindWord[f.e.kind] + '. ' + (s.count > 1 ? A.series(s.past, s.coming) : A.once)),
282
+ ...whyLines(f.e).map((t) => el('p', null, t)),
283
+ last ? el('details', { class: 'td-ag__last', 'data-ag': 'last' }, el('summary', null, A.lastTime(dayLabel(last.date))),
284
+ last.notes ? el('p', null, last.notes) : el('p', { class: 'td-day__note' }, A.noLast),
285
+ last.open && last.open.length ? el('ul', { class: 'td-day__chk' }, ...last.open.map((x) => el('li', null, x))) : null) : null);
286
+ }
287
+
288
+ export function meetingDetail(ctx, id, h) {
289
+ const f = findEvent(id);
290
+ if (!f) return null;
291
+ const e = f.e;
292
+ const end = (() => { const d = new Date(e.end); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; })();
293
+ return [
294
+ h.dhead(A.kindWord[e.kind], e.title),
295
+ el('p', { class: 'td-day__dim td-day__mono' }, `${dayLabel(f.day.date)} · ${e.time}–${end}${e.attendees ? ' · ' + A.people(e.attendees) : ''}`),
296
+ memoryBlock(id),
297
+ e.prep ? el('div', { class: 'td-day__phase', 'data-ag': 'before' }, el('h4', null, A.before),
298
+ el('p', null, A.prepLine(e.prep_done || 0, e.prep.length)),
299
+ el('ul', { class: 'td-day__chk' }, ...e.prep.map((t, i) => el('li', { class: i < (e.prep_done || 0) ? 'td-day__ok' : '' }, t))),
300
+ el('p', { class: e.ready === 'risk' ? 'td-day__warn' : 'td-day__note', 'data-ag-ready': e.ready || 'ok' }, A.readyNote[e.ready || 'ok'])) : null,
301
+ e.ended ? el('div', { class: 'td-day__phase', 'data-ag': 'notes' }, el('h4', null, A.afterNotes),
302
+ e.notes ? el('p', null, e.notes) : el('p', { class: 'td-day__note' }, A.noNotesYet),
303
+ e.open && e.open.length ? [el('h4', null, A.openLines), el('ul', { class: 'td-day__chk' }, ...e.open.map((x) => el('li', null, x)))] : null) : null,
304
+ ];
305
+ }