create-pmos 0.129.0 → 0.131.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 (48) 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/c3-one-home-test.js +8 -3
  7. package/workspace/c3-work-test.js +200 -0
  8. package/workspace/fixtures/one-home-baseline.json +0 -91
  9. package/workspace/guide/briefing.md +60 -0
  10. package/workspace/guide/calendar-popup.md +46 -0
  11. package/workspace/guide/console-calendar.md +65 -0
  12. package/workspace/guide/index.md +4 -0
  13. package/workspace/guide/layout.md +1 -0
  14. package/workspace/guide/my-work.md +76 -1
  15. package/workspace/lib/calendar-layers.js +372 -0
  16. package/workspace/lib/pm-items.js +71 -6
  17. package/workspace/lib/report-needs.js +1 -1
  18. package/workspace/lib/route-modules.js +1 -0
  19. package/workspace/lib/routes-brief-page.js +187 -0
  20. package/workspace/lib/routes-calendar-views.js +112 -3
  21. package/workspace/lib/routes-pm-items.js +123 -13
  22. package/workspace/lib/work-focus.js +106 -0
  23. package/workspace/package-lock.json +2 -2
  24. package/workspace/package.json +1 -1
  25. package/workspace/public/pm/c3-brief-data.js +11 -0
  26. package/workspace/public/pm/c3-brief-needs.js +145 -0
  27. package/workspace/public/pm/c3-brief-pulse.js +70 -0
  28. package/workspace/public/pm/c3-brief-today.js +44 -0
  29. package/workspace/public/pm/c3-calendar.js +392 -0
  30. package/workspace/public/pm/c3-calpop.js +103 -0
  31. package/workspace/public/pm/c3-demo-brief-page.js +54 -0
  32. package/workspace/public/pm/c3-demo-cal4.js +114 -0
  33. package/workspace/public/pm/c3-demo-data.js +79 -77
  34. package/workspace/public/pm/c3-demo-misc2.js +4 -16
  35. package/workspace/public/pm/c3-demo-work-ix.js +19 -67
  36. package/workspace/public/pm/c3-demo.js +11 -4
  37. package/workspace/public/pm/c3-shell.js +27 -149
  38. package/workspace/public/pm/calendar-views.js +11 -3
  39. package/workspace/public/pm/components.css +1 -0
  40. package/workspace/public/pm/console3.css +198 -0
  41. package/workspace/public/pm/pm-work-view.js +364 -172
  42. package/workspace/public/pm/work-place.js +58 -0
  43. package/workspace/test-files.json +7 -1
  44. package/workspace/test-lists/calendar-layers.json +5 -0
  45. package/workspace/test-lists/calendar-time-page.json +5 -0
  46. package/workspace/test-lists/calpop.json +4 -0
  47. package/workspace/test-lists/console3-work-page.json +9 -0
  48. package/workspace/public/pm/c3-demo-work2.js +0 -13
@@ -0,0 +1,392 @@
1
+ /* pm/c3-calendar.js — the Calendar tab of /c3.html as a page about time (initiative calendar-time-page, PRD R1 to R8).
2
+ *
3
+ * calendar-views.js mounts it: mountCalendarViews(host, { page: true, ... }) makes draw() hand the instance to
4
+ * drawCalendarPage(host, r, helpers). That file keeps the data call (GET /api/calendar-views), the zoom bar, the Today
5
+ * chip and the swipe, pinch and key gestures; the helpers it passes are its own, so nothing is copied here.
6
+ *
7
+ * Top to bottom: the page line and the zoom bar (Day, Week, Month, Quarter, Year), the layer chips (Meetings, Milestones
8
+ * and due dates, Releases, Report dates, My due items, then Free time), the grid of the zoom with the layer markers on it,
9
+ * and the detail column (under the grid on a narrow screen, at the right from 1100px).
10
+ *
11
+ * What it reads from the answer, all optional and additive: `cells` (the meetings), `layers` (the dated items, each with
12
+ * kind, layer, tone and a reference to its home), `free` (free blocks with a suggested use or none), `context` (what came
13
+ * out of a past meeting and what moved after it; what is prepared and what to prepare for a coming one) and `layers_note`.
14
+ * A key that is missing reads as "the server sent none", in words. Nothing is invented.
15
+ *
16
+ * What it does: it selects (a meeting, a marker) and it navigates (a zoom, a day, a reference to the home of an item).
17
+ * It writes nothing. A marker carries no action: its detail has one reference link that opens the item where it lives. */
18
+ import { el } from './dom.js';
19
+ import { c3ic } from './pm-work-view.js';
20
+ import { button } from './primitives.js';
21
+
22
+ const LAYERS = [
23
+ ['meetings', 'Meetings'],
24
+ ['milestones', 'Milestones and due dates'],
25
+ ['releases', 'Releases'],
26
+ ['reports', 'Report dates'],
27
+ ['mine', 'My due items'],
28
+ ];
29
+ const KIND = {
30
+ milestone: ['Milestone', 'flag'], due: ['Due date', 'clock'], release: ['Release', 'bolt'], report: ['Report date', 'report'], own: ['My due item', 'todo'],
31
+ };
32
+ const RANK = { milestone: 0, release: 1, report: 2, due: 3, own: 4 };
33
+ const TONE = { bad: 'Late or at risk of missing', warn: 'At risk', ok: 'On track' };
34
+ const REF = { initiative: 'Open the initiative', 'plan-item': 'Open the initiative', item: 'Open in Work', release: 'Open the release', report: 'Open in Reports' };
35
+ const CTXWORD = { decision: 'Decision', action: 'Action', moved: 'Moved', prepared: 'Prepared', todo: 'To prepare' };
36
+ const NO_SUGGESTION = 'Free, nothing specific to suggest';
37
+ const PPH = 38; // pixels per hour on the Day and Week timelines (the mockup's vday)
38
+
39
+ const pad = (n) => String(n).padStart(2, '0');
40
+ const nowIso = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; };
41
+ const plural = (n, one, many) => n + ' ' + (n === 1 ? one : (many || one + 's'));
42
+ const kindOf = (m) => KIND[m.kind] || ['Dated item', 'cal'];
43
+ const hmToH = (s) => { const m = /^(\d{1,2}):(\d{2})/.exec(String(s || '')); return m ? Number(m[1]) + Number(m[2]) / 60 : null; };
44
+ const setVar = (node, name, value) => { node.style.setProperty('--cal3-' + name, String(value)); return node; };
45
+
46
+ /* ── the page's own state on the instance: which layers are on, whether free time shows, what is selected ─────────── */
47
+
48
+ function state(r) {
49
+ const st = r.page || (r.page = { on: new Set(LAYERS.map((l) => l[0])), free: false, sel: null });
50
+ if (r.sel) { st.sel = { type: 'event', id: r.sel }; r.sel = null; } // a meeting chosen on the Briefing (openDay)
51
+ return st;
52
+ }
53
+
54
+ /* ── what the answer holds ────────────────────────────────────────────────────────────────────────────────────────── */
55
+
56
+ function model(d, h) {
57
+ const view = d.view || (d.range && d.range.view) || 'week';
58
+ const cells = d.cells || [];
59
+ const meetingsOf = (c) => (c.events || []).filter((e) => e.kind !== 'milestone'); // a milestone event is the older marker; the layers carry dates now
60
+ const meetings = cells.flatMap((c) => meetingsOf(c).map((e) => ({ e, date: String(e.at || c.start).slice(0, 10) })));
61
+ const layers = Array.isArray(d.layers) ? d.layers : null;
62
+ const free = d.free && Array.isArray(d.free.days) ? d.free : null;
63
+ const freeBy = new Map(); if (free) for (const x of free.days) freeBy.set(x.date, x.blocks || []);
64
+ const ctx = d.context && typeof d.context === 'object' ? d.context : null;
65
+ const findEvent = (id) => meetings.find((m) => m.e.id === id) || meetings.find((m) => h.baseId(m.e) === id) || null;
66
+ return { view, cells, meetingsOf, meetings, layers, free, freeBy, ctx, findEvent };
67
+ }
68
+
69
+ const sortMarks = (a, b) => (a.tone === 'bad' ? 0 : 1) - (b.tone === 'bad' ? 0 : 1) || (RANK[a.kind] ?? 9) - (RANK[b.kind] ?? 9) || String(a.date).localeCompare(String(b.date));
70
+ function marksIn(M, st, a, b) {
71
+ if (!M.layers) return [];
72
+ return M.layers.filter((m) => st.on.has(m.layer) && m.date >= a && m.date <= b).sort(sortMarks);
73
+ }
74
+ const freeIn = (M, a, b) => (M.free ? M.free.days.filter((x) => x.date >= a && x.date <= b).flatMap((x) => (x.blocks || []).map((bl) => ({ ...bl, date: x.date }))) : []);
75
+
76
+ /* ── the page ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
77
+
78
+ /** Draw the Calendar tab of instance r into a new node. h is calendar-views.js's own helpers (see PAGE there). */
79
+ export function drawCalendarPage(host, r, h) {
80
+ const st = state(r);
81
+ const phone = r.root.classList.contains('ph');
82
+ const head = el('div', { class: 'cal3-head', 'data-cal3': 'head' }, el('h2', null, 'Calendar'),
83
+ el('span', { class: 'dim' }, 'What happens by when: meetings, dates and free time on one grid.'));
84
+ const bar = h.bar(host, r, phone);
85
+ const page = (...kids) => el('div', { class: 'cal3', 'data-cal3': 'page' }, head, bar, ...kids);
86
+ if (r.error) return page(fold('alert', 'The calendar could not be read: ' + r.error, { 'data-cv': 'error', role: 'status' }));
87
+ if (!r.data) return page(fold('clock', 'Reading the repository', { 'data-cv': 'loading', role: 'status' }));
88
+
89
+ const d = r.data; const M = model(d, h);
90
+ const redraw = (focusSel) => { h.redraw(host, r); if (focusSel) { const n = r.root.querySelector(focusSel); if (n) n.focus(); } };
91
+ const select = (sel, focusSel) => {
92
+ st.sel = sel; redraw(focusSel);
93
+ const det = r.root.querySelector('[data-cal3="detail"]');
94
+ if (det && det.scrollIntoView && r.root.getBoundingClientRect().width < 1100) det.scrollIntoView({ block: 'nearest' });
95
+ };
96
+ const ui = { host, r, h, st, M, d, redraw, select };
97
+
98
+ const range = d.range || { start: d.today, end: d.today };
99
+ const nMeet = st.on.has('meetings') ? M.meetings.length : 0;
100
+ const nMarks = marksIn(M, st, range.start, range.end).length;
101
+ const empty = !nMeet && !nMarks ? fold('moon', M.meetings.length || (M.layers && M.layers.length)
102
+ ? 'Every layer with items in this range is turned off. Turn a layer on to see it.'
103
+ : d.events_source === 'none' && !M.layers ? 'No calendar is cached yet, and the server sent no dated items. Nothing is drawn.'
104
+ : 'Nothing is scheduled and nothing falls due in this range.', { 'data-cal3': 'empty' }) : null;
105
+
106
+ let grid;
107
+ if (M.view === 'day' || M.view === 'week') {
108
+ const days = []; for (let x = range.start; x <= range.end && days.length < 7; x = h.shift('day', x, 1)) days.push(x);
109
+ grid = timeGrid(ui, days);
110
+ } else if (M.view === 'month') grid = monthGrid(ui);
111
+ else if (M.view === 'quarter') grid = quarterRows(ui);
112
+ else grid = yearCards(ui);
113
+
114
+ const body = el('div', { class: 'calbody' + (r.anim && !r.loading ? ' ' + r.anim : '') },
115
+ el('div', { class: 'cal3-wrap' },
116
+ el('div', { class: 'cal3-main' }, empty, grid),
117
+ el('div', { class: 'cal3-side' }, detail(ui), st.free ? freeList(ui, range) : null, lanes(ui, range))));
118
+ return page(chips(ui, range), body);
119
+ }
120
+
121
+ function fold(icon, text, attrs) { return el('div', { class: 'fold', ...(attrs || {}) }, c3ic(icon, 'sm'), text); }
122
+
123
+ /* ── the layer chips ──────────────────────────────────────────────────────────────────────────────────────────────── */
124
+
125
+ function chips(ui, range) {
126
+ const { st, M } = ui;
127
+ const count = (id) => (id === 'meetings' ? M.meetings.length : M.layers ? M.layers.filter((m) => m.layer === id && m.date >= range.start && m.date <= range.end).length : null);
128
+ const chip = (id, label, n, on, toggle, lead) => button({
129
+ label: [lead, label, n === null ? null : el('span', { class: 'cal3-n' }, String(n))], variant: 'ghost', size: 'sm',
130
+ attrs: { class: 'cal3-chip', 'aria-pressed': String(on), 'data-cal3-chip': id, title: (on ? 'Hide ' : 'Show ') + label },
131
+ onClick: () => { toggle(); ui.redraw('[data-cal3-chip="' + id + '"]'); },
132
+ });
133
+ const freeN = M.free ? (typeof M.free.count === 'number' ? M.free.count : freeIn(M, range.start, range.end).length) : null;
134
+ return el('div', { class: 'cal3-chips', role: 'group', 'aria-label': 'Layers', 'data-cal3': 'chips' },
135
+ ...LAYERS.map(([id, label]) => chip(id, label, count(id), st.on.has(id), () => { if (st.on.has(id)) st.on.delete(id); else st.on.add(id); }, el('i', { class: 'cal3-sw cal3-sw-' + id }))),
136
+ el('span', { class: 'cal3-sep', 'aria-hidden': 'true' }),
137
+ chip('free', 'Free time', freeN, st.free, () => { st.free = !st.free; }, c3ic('clock', 'sm')));
138
+ }
139
+
140
+ /* ── a marker: glyph and short title, tone from the tokens; pressing it selects it (the detail has the reference) ─── */
141
+
142
+ function marker(ui, m, compact) {
143
+ const [word, icon] = kindOf(m);
144
+ const sel = ui.st.sel && ui.st.sel.type === 'marker' && ui.st.sel.id === m.id;
145
+ const name = word + ': ' + m.title + ', ' + ui.h.dayLabel(m.date) + (m.tone && TONE[m.tone] ? '. ' + TONE[m.tone] : '');
146
+ return button({
147
+ label: [c3ic(icon, 'sm'), el('span', { class: 'cal3-mk-t' }, m.title)], variant: 'ghost', size: 'sm',
148
+ attrs: { class: 'cal3-mk cal3-k-' + (KIND[m.kind] ? m.kind : 'other') + (m.tone ? ' cal3-tone-' + m.tone : '') + (compact ? ' compact' : '') + (sel ? ' sel' : ''),
149
+ 'data-cal3-mk': m.id, 'data-cal3-layer': m.layer, 'data-cal3-kind': m.kind, 'aria-pressed': String(!!sel), 'aria-label': name, title: name },
150
+ onClick: () => ui.select({ type: 'marker', id: m.id }, '[data-cal3-mk="' + cssId(m.id) + '"]'),
151
+ });
152
+ }
153
+ const cssId = (s) => String(s).replace(/["\\]/g, '\\$&');
154
+
155
+ /* the markers of a span, the most important first, at most `max`, and a "+N" for the rest */
156
+ function markRow(ui, a, b, max, compact) {
157
+ const all = marksIn(ui.M, ui.st, a, b);
158
+ return [...all.slice(0, max).map((m) => marker(ui, m, compact)), all.length > max ? el('span', { class: 'cal3-more', title: plural(all.length - max, 'more dated item') }, '+' + (all.length - max)) : null];
159
+ }
160
+
161
+ /* ── Day and Week: the timeline with the all-day marker strip above it ───────────────────────────────────────────── */
162
+
163
+ function timeGrid(ui, days) {
164
+ const { M, st, h, d } = ui;
165
+ const now = nowIso(); const today = d.today;
166
+ const timedOn = (date) => (st.on.has('meetings') ? M.meetings.filter((m) => m.date === date && h.timeOf(m.e.at) !== null) : []);
167
+ const allDayOn = (date) => (st.on.has('meetings') ? M.meetings.filter((m) => m.date === date && h.timeOf(m.e.at) === null) : []);
168
+ const all = days.flatMap(timedOn);
169
+ const fr = M.free || {};
170
+ const t0 = Math.min(Math.floor(hmToH(fr.day_start) ?? 8), 8, ...all.map((m) => Math.floor(h.timeOf(m.e.at))));
171
+ const t1 = Math.max(Math.ceil(hmToH(fr.day_end) ?? 18), 18, ...all.map((m) => Math.ceil(h.endH(m.e))));
172
+ const Y = (t) => Math.round((Math.max(t0, Math.min(t1, t)) - t0) * PPH);
173
+
174
+ const tg = el('div', { class: 'cal3-tg' + (days.length === 1 ? ' one' : ''), 'data-cal3': days.length === 1 ? 'day' : 'week' });
175
+ setVar(tg, 'n', days.length); setVar(tg, 'hgt', (t1 - t0) * PPH + 'px'); setVar(tg, 'pph', PPH + 'px');
176
+
177
+ const headRow = el('div', { class: 'cal3-row cal3-dhead' }, el('div', { class: 'cal3-gut' }),
178
+ ...days.map((date) => el('div', { class: 'cal3-dh' + (date === today ? ' today' : '') },
179
+ days.length > 1 ? button({ label: h.dayLabel(date), variant: 'ghost', size: 'sm', attrs: { class: 'cal3-dbtn', 'data-cal3-day': date, title: 'Open ' + h.dayLabel(date) }, onClick: () => h.go(ui.host, ui.r, { view: 'day', date, anim: 'zi' }) })
180
+ : el('b', null, h.dayLabel(date)))));
181
+
182
+ const strip = el('div', { class: 'cal3-row cal3-strip', 'data-cal3': 'strip' }, el('div', { class: 'cal3-gut' }, 'dates'),
183
+ ...days.map((date) => {
184
+ const kids = [...allDayOn(date).map((m) => meetingButton(ui, m, 'cal3-allday')), ...markRow(ui, date, date, days.length === 1 ? 12 : 4, days.length > 1)];
185
+ return el('div', { class: 'cal3-sc', 'data-cal3-strip': date }, ...(kids.filter(Boolean).length ? kids : [el('span', { class: 'cal3-none' }, days.length === 1 ? 'No dated item on this day' : '')]));
186
+ }));
187
+
188
+ const hours = el('div', { class: 'cal3-hours', 'aria-hidden': 'true' });
189
+ for (let t = t0; t < t1; t++) hours.appendChild(setVar(el('span', null, pad(t) + ':00'), 'y', Y(t) + 'px'));
190
+
191
+ const cols = days.map((date) => {
192
+ const wd = h.dowOf(date);
193
+ const col = el('div', { class: 'vday cal3-col' + (date === today ? ' today' : '') + (wd === 0 || wd === 6 ? ' we' : ''), 'data-cal3-col': date });
194
+ if (st.free) for (const b of (M.freeBy.get(date) || [])) col.appendChild(freeBlock(ui, date, b, Y));
195
+ const evs = timedOn(date).sort((a, b) => h.timeOf(a.e.at) - h.timeOf(b.e.at));
196
+ lanes4(evs, h).forEach(([m, lane, n]) => {
197
+ const s = h.timeOf(m.e.at); const f = h.endH(m.e);
198
+ const b = meetingButton(ui, m, 'vb cal3-ev' + (String(m.e.end || m.e.at) <= now ? ' past' : '') + (m.e.routine || m.e.mock_routine ? ' rt' : ''), [el('b', null, m.e.title || m.e.id), f - s >= 1 ? el('small', null, h.hhmm(s) + ' to ' + h.hhmm(f)) : null]);
199
+ setVar(b, 'y', (Y(s) + 1) + 'px'); setVar(b, 'h', Math.max(16, Y(f) - Y(s) - 3) + 'px'); setVar(b, 'l', lane); setVar(b, 'lc', n);
200
+ col.appendChild(b);
201
+ });
202
+ if (date === today && now.slice(0, 10) === today) { const t = hmToH(now.slice(11)); if (t > t0 && t < t1) col.appendChild(setVar(el('div', { class: 'vnow cal3-now', 'aria-hidden': 'true' }), 'y', Y(t) + 'px')); }
203
+ return col;
204
+ });
205
+ tg.append(headRow, strip, el('div', { class: 'cal3-row cal3-body' }, hours, ...cols));
206
+ return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, el('div', { class: 'cal3-scroll' }, tg));
207
+ }
208
+
209
+ /* overlapping meetings share the column side by side: [meeting, lane, lanes in its cluster] */
210
+ function lanes4(evs, h) {
211
+ const out = []; let cluster = []; let cEnd = -1;
212
+ const flush = () => { const ends = []; const at = cluster.map((m) => { let l = ends.findIndex((x) => x <= h.timeOf(m.e.at)); if (l < 0) { l = ends.length; ends.push(h.endH(m.e)); } else ends[l] = h.endH(m.e); return [m, l]; }); at.forEach(([m, l]) => out.push([m, l, ends.length])); cluster = []; };
213
+ for (const m of evs) { if (cluster.length && h.timeOf(m.e.at) >= cEnd) flush(); cluster.push(m); cEnd = Math.max(cEnd, h.endH(m.e)); }
214
+ if (cluster.length) flush();
215
+ return out;
216
+ }
217
+
218
+ function meetingButton(ui, m, cls, label) {
219
+ const sel = ui.st.sel && ui.st.sel.type === 'event' && (ui.st.sel.id === m.e.id || ui.st.sel.id === ui.h.baseId(m.e));
220
+ const t = m.e.title || m.e.id; const s = ui.h.timeOf(m.e.at);
221
+ const name = t + ', ' + ui.h.dayLabel(m.date) + (s !== null ? ' at ' + ui.h.hhmm(s) : ', all day');
222
+ return button({
223
+ label: label || t, variant: 'ghost', size: 'sm',
224
+ attrs: { class: cls + (sel ? ' sel' : ''), 'data-id': m.e.id, 'data-cal3-ev': m.e.id, 'aria-pressed': String(!!sel), 'aria-label': name, title: name },
225
+ onClick: () => ui.select({ type: 'event', id: m.e.id }, '[data-cal3-ev="' + cssId(m.e.id) + '"]'),
226
+ });
227
+ }
228
+
229
+ /* a free block: the existing hatched window (.vf) with its suggested use, as text only */
230
+ function freeBlock(ui, date, b, Y) {
231
+ const s = hmToH(b.from); const f = hmToH(b.to);
232
+ const n = el('div', { class: 'vf cal3-free', 'data-cal3-free': date + ' ' + b.from },
233
+ el('b', null, b.from + ' to ' + b.to + ' free'), el('span', null, b.suggestion && b.suggestion.text ? b.suggestion.text : NO_SUGGESTION));
234
+ setVar(n, 'y', (Y(s) + 2) + 'px'); setVar(n, 'h', Math.max(18, Y(f) - Y(s) - 4) + 'px');
235
+ return n;
236
+ }
237
+
238
+ /* ── Month: a cell per day with the meeting count, up to three markers and +N, and the free count ───────────────── */
239
+
240
+ function monthGrid(ui) {
241
+ const { M, st, h, d } = ui;
242
+ const days = M.cells.filter((c) => c.start === c.end);
243
+ const g = el('div', { class: 'cal3-month', 'data-cal3': 'month' }, ...['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((n) => el('div', { class: 'cal3-wd' }, n)));
244
+ const off = days.length ? (h.dowOf(days[0].start) + 6) % 7 : 0;
245
+ for (let i = 0; i < off; i++) g.appendChild(el('div', { class: 'cal3-mc out', 'aria-hidden': 'true' }));
246
+ for (const c of days) {
247
+ const wd = h.dowOf(c.start); const n = st.on.has('meetings') ? M.meetingsOf(c).length : 0;
248
+ const fr = st.free ? (M.freeBy.get(c.start) || []).length : 0;
249
+ g.appendChild(el('div', { class: 'cal3-mc' + (wd === 0 || wd === 6 ? ' we' : '') + (c.start === d.today ? ' today' : ''), 'data-cal3-date': c.start },
250
+ button({ label: String(Number(c.start.slice(8))), variant: 'ghost', size: 'sm', attrs: { class: 'cal3-dn', 'data-cal3-day': c.start, 'aria-label': 'Open ' + h.dayLabel(c.start) + (n ? ', ' + plural(n, 'meeting') : ''), title: 'Open ' + h.dayLabel(c.start) }, onClick: () => h.go(ui.host, ui.r, { view: 'day', date: c.start, anim: 'zi' }) }),
251
+ n ? el('span', { class: 'cal3-cnt' }, plural(n, 'meeting')) : null,
252
+ ...markRow(ui, c.start, c.start, 3, true),
253
+ fr ? el('span', { class: 'cal3-fcnt', 'data-cal3-fcnt': c.start }, fr + ' free') : null));
254
+ }
255
+ return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, g);
256
+ }
257
+
258
+ /* ── Quarter: a row per week; Year: a card per month. Counts, and the markers of the most important kinds ────────── */
259
+
260
+ function spanCounts(ui, c) {
261
+ const { M, st } = ui;
262
+ const n = st.on.has('meetings') ? M.meetingsOf(c).length : 0;
263
+ const fr = st.free ? freeIn(M, c.start, c.end).length : 0;
264
+ return el('div', { class: 'cal3-counts' },
265
+ st.on.has('meetings') ? el('span', { class: 'tag' }, plural(n, 'meeting')) : null,
266
+ el('span', { class: 'tag' }, plural(marksIn(M, st, c.start, c.end).length, 'dated item')),
267
+ st.free ? el('span', { class: 'tag cal3-ftag' }, fr + ' free') : null);
268
+ }
269
+
270
+ function quarterRows(ui) {
271
+ const { M, h, d } = ui;
272
+ const rows = M.cells.map((c) => el('div', { class: 'cal3-qrow' + (c.start <= d.today && d.today <= c.end ? ' now' : ''), 'data-cal3-week': c.start },
273
+ button({ label: 'Week of ' + h.dayLabel(c.start), variant: 'ghost', size: 'sm', attrs: { class: 'cal3-dbtn', title: 'Open the week of ' + h.dayLabel(c.start) }, onClick: () => h.go(ui.host, ui.r, { view: 'week', date: c.start, anim: 'zi' }) }),
274
+ spanCounts(ui, c), el('div', { class: 'cal3-qmk' }, ...markRow(ui, c.start, c.end, 4, false))));
275
+ return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, el('div', { class: 'cal3-quarter', 'data-cal3': 'quarter' }, ...rows));
276
+ }
277
+
278
+ function yearCards(ui) {
279
+ const { M, h, d } = ui;
280
+ const cards = M.cells.map((c) => el('div', { class: 'cal3-ym' + (c.start <= d.today && d.today <= c.end ? ' now' : ''), 'data-cal3-month': c.start.slice(0, 7) },
281
+ button({ label: h.MN[Number(c.start.slice(5, 7)) - 1] + ' ' + c.start.slice(0, 4), variant: 'ghost', size: 'sm', attrs: { class: 'cal3-dbtn', title: 'Open the month' }, onClick: () => h.go(ui.host, ui.r, { view: 'month', date: c.start, anim: 'zi' }) }),
282
+ spanCounts(ui, c), el('div', { class: 'cal3-qmk' }, ...markRow(ui, c.start, c.end, 3, false))));
283
+ return el('section', { class: 'card cal3-grid', 'data-cv': 'grid' }, el('div', { class: 'cal3-year', 'data-cal3': 'year' }, ...cards));
284
+ }
285
+
286
+ /* ── the detail column ────────────────────────────────────────────────────────────────────────────────────────────── */
287
+
288
+ function refLink(ui, ref, extra) {
289
+ if (!ref || !ref.type || !ref.id) return el('span', { class: 'dim cal3-noptr' }, 'no pointer');
290
+ if (!REF[ref.type]) return el('span', { class: 'dim mono cal3-noptr' }, ref.type + ' ' + ref.id);
291
+ return el('a', { href: '#', class: 'cal3-ref', 'data-cal3-ref': ref.type + ':' + ref.id, title: REF[ref.type] + ': ' + ref.id,
292
+ onClick: (ev) => { ev.preventDefault(); openRef(ui, ref, extra || {}); } }, c3ic('out', 'sm'), REF[ref.type]);
293
+ }
294
+
295
+ /* open the home of a reference: an initiative on Initiatives, a work item on Work, a report on Reports */
296
+ function openRef(ui, ref, extra) {
297
+ const { h, host, r } = ui;
298
+ if (ref.type === 'initiative') { if (!h.goInitiative(ref.id)) h.openInit(host, r, ref.id, extra.title); return; }
299
+ if (ref.type === 'plan-item' || ref.type === 'release') { const ini = extra.initiative || ref.initiative; if (!(ini && h.goInitiative(ini))) h.gotoTab('init'); return; }
300
+ if (ref.type === 'item') { h.gotoTab('work'); return; }
301
+ if (ref.type === 'report') h.gotoTab('rep');
302
+ }
303
+
304
+ function closeBtn(ui) {
305
+ return button({ label: 'Close', variant: 'ghost', size: 'sm', attrs: { class: 'cal3-close', 'data-cal3': 'close' }, onClick: () => { ui.st.sel = null; ui.redraw('[data-cal3-chip="meetings"]'); } });
306
+ }
307
+
308
+ function detail(ui) {
309
+ const { st, M, h } = ui;
310
+ const box = (kids, attrs) => el('section', { class: 'card cal3-detail', 'data-cal3': 'detail', 'aria-label': 'Detail', ...(attrs || {}) }, ...kids);
311
+ if (st.sel && st.sel.type === 'marker' && M.layers) {
312
+ const m = M.layers.find((x) => x.id === st.sel.id);
313
+ if (m) {
314
+ const [word, icon] = kindOf(m);
315
+ return box([
316
+ el('div', { class: 'cal3-dt' }, el('h3', null, c3ic(icon), word), closeBtn(ui)),
317
+ el('b', { class: 'cal3-title' }, m.title),
318
+ el('div', { class: 'chips2' }, el('span', { class: 'tag' }, word), el('span', { class: 'tag' }, h.dayLabel(m.date) + (m.end && m.end !== m.date ? ' to ' + h.dayLabel(m.end) : '')),
319
+ m.tone && TONE[m.tone] ? el('span', { class: 'tag cal3-tone-' + m.tone }, TONE[m.tone]) : null, m.initiative ? el('span', { class: 'tag' }, 'Initiative: ' + (((ui.d.gantt || []).find((g) => g.id === m.initiative) || {}).title || m.initiative)) : null),
320
+ el('div', { class: 'cal3-open', 'data-cal3': 'open' }, refLink(ui, m.ref, { initiative: m.initiative, title: m.title })),
321
+ el('p', { class: 'dim cal3-note' }, 'A marker shows the date only. You act on the item where it lives.'),
322
+ ], { 'data-cal3-sel': m.id });
323
+ }
324
+ }
325
+ if (st.sel && st.sel.type === 'event') {
326
+ const hit = M.findEvent(st.sel.id);
327
+ if (hit) return box(meetingDetail(ui, hit), { 'data-cal3-sel': hit.e.id });
328
+ }
329
+ return box([el('div', { class: 'cal3-dt' }, el('h3', null, c3ic('info'), 'Detail')),
330
+ el('p', { class: 'dim cal3-note' }, 'Select a meeting to see what came out of it or what to prepare. Select a dated marker to see its date and open its home.')]);
331
+ }
332
+
333
+ function meetingDetail(ui, hit) {
334
+ const { M, h } = ui; const e = hit.e;
335
+ const s = h.timeOf(e.at); const f = s === null ? null : h.endH(e);
336
+ const rec = M.ctx ? (M.ctx[e.id] || M.ctx[h.baseId(e)] || null) : null;
337
+ const phase = rec && rec.phase ? rec.phase : String(e.end || e.at) <= nowIso() ? 'past' : 'upcoming';
338
+ const past = phase === 'past';
339
+ const items = rec && Array.isArray(rec.items) ? rec.items : [];
340
+ const list = (kinds, title, key, none) => {
341
+ const rows = items.filter((x) => kinds.includes(x.kind));
342
+ return el('div', { class: 'cal3-ctx', 'data-cal3-ctx': key }, el('h4', null, title),
343
+ rows.length ? el('ul', { class: 'cal3-cl', role: 'list' }, ...rows.map((x) => el('li', { class: 'cal3-ci', 'data-cal3-item': x.kind },
344
+ el('span', { class: 'tag' }, CTXWORD[x.kind] || x.kind), el('span', { class: 'cal3-ct' }, x.text),
345
+ x.at ? el('span', { class: 'dim mono cal3-at' }, h.dayLabel(x.at) + (h.timeOf(x.at) !== null ? ' ' + h.hhmm(h.timeOf(x.at)) : '')) : null,
346
+ refLink(ui, x.ref, { title: x.text })))) : el('p', { class: 'dim cal3-note' }, none));
347
+ };
348
+ let body;
349
+ if (!M.ctx) body = [fold('info', 'The calendar sent no meeting context, so PMOS shows nothing for this meeting.', { 'data-cal3': 'no-context' })];
350
+ else if (!rec || rec.held === false) body = [fold('moon', 'PMOS holds nothing for this meeting: no note, no decision and no linked item.', { 'data-cal3': 'nothing-held' })];
351
+ else if (past) body = [list(['decision', 'action'], 'What came out of it', 'out', 'No decision or action is recorded.'), list(['moved'], 'What moved after it', 'moved', 'Nothing moved within 48 hours that refers to it.')];
352
+ else body = [list(['prepared'], 'Prepared', 'prepared', 'Nothing is prepared yet.'), list(['todo'], 'To prepare', 'todo', 'Nothing is left to prepare.')];
353
+ return [
354
+ el('div', { class: 'cal3-dt' }, el('h3', null, c3ic('cal'), 'Meeting'), el('span', { class: 'tag' + (past ? '' : ' cal3-up') }, past ? 'past' : 'upcoming'), closeBtn(ui)),
355
+ el('b', { class: 'cal3-title' }, e.title || e.id),
356
+ el('div', { class: 'dim mono cal3-when' }, h.dayLabel(hit.date) + (s === null ? ', all day' : ' · ' + h.hhmm(s) + ' to ' + h.hhmm(f))),
357
+ ...body,
358
+ rec && rec.held !== false ? el('p', { class: 'dim cal3-note' }, 'Only what PMOS holds a record for. Each line opens its source.') : null,
359
+ ];
360
+ }
361
+
362
+ /* the free blocks of the range in words, with the suggested use or none; nothing is written */
363
+ function freeList(ui, range) {
364
+ const { M, h } = ui;
365
+ const head = el('h3', null, c3ic('clock'), 'Free time');
366
+ if (!M.free) return el('section', { class: 'card cal3-freel', 'data-cal3': 'free-list' }, head, fold('info', 'The calendar sent no free time for this range.', { 'data-cal3': 'no-free' }));
367
+ const all = freeIn(M, range.start, range.end);
368
+ const cap = 12;
369
+ return el('section', { class: 'card cal3-freel', 'data-cal3': 'free-list' }, head,
370
+ el('p', { class: 'dim cal3-note' }, 'Blocks of ' + (M.free.min_minutes || 90) + ' minutes or more between ' + (M.free.day_start || '09:00') + ' and ' + (M.free.day_end || '18:00') + '. A suggestion is a proposal: PMOS writes nothing.'),
371
+ all.length ? el('ul', { class: 'cal3-cl', role: 'list' }, ...all.slice(0, cap).map((b) => el('li', { class: 'cal3-ci', 'data-cal3-fb': b.date + ' ' + b.from },
372
+ el('span', { class: 'dim mono cal3-at' }, h.dayLabel(b.date) + ' ' + b.from + ' to ' + b.to),
373
+ el('span', { class: 'cal3-ct' }, b.suggestion && b.suggestion.text ? b.suggestion.text : NO_SUGGESTION),
374
+ b.suggestion && b.suggestion.ref ? refLink(ui, b.suggestion.ref, { title: b.suggestion.text }) : null)))
375
+ : el('p', { class: 'dim cal3-note' }, 'No free block of ' + (M.free.min_minutes || 90) + ' minutes or more in this range.'),
376
+ all.length > cap ? el('p', { class: 'dim mono cal3-note' }, '+' + (all.length - cap) + ' more blocks in this range') : null);
377
+ }
378
+
379
+ /* each lane in words: its count in the range, or why it is empty */
380
+ function lanes(ui, range) {
381
+ const { M, st, d } = ui;
382
+ const notes = d.layers_note && typeof d.layers_note === 'object' ? d.layers_note : {};
383
+ const rows = LAYERS.map(([id, label]) => {
384
+ const n = id === 'meetings' ? M.meetings.length : M.layers ? M.layers.filter((m) => m.layer === id && m.date >= range.start && m.date <= range.end).length : null;
385
+ const why = n ? plural(n, id === 'meetings' ? 'meeting' : 'item') + (st.on.has(id) ? '' : ', hidden')
386
+ : id === 'meetings' ? (d.events_source === 'none' ? 'No calendar is cached yet.' : 'No meeting in this range.')
387
+ : n === null ? 'The calendar sent no dated items, so this layer is empty.' : notes[id] || 'Nothing in this range.';
388
+ return el('li', { class: 'cal3-lane', 'data-cal3-lane': id }, el('i', { class: 'cal3-sw cal3-sw-' + id }), el('span', null, label), el('span', { class: 'dim cal3-why' }, why));
389
+ });
390
+ const extra = Object.keys(notes).filter((k) => !LAYERS.some((l) => l[0] === k)).map((k) => el('li', { class: 'cal3-lane dim' }, el('span', null, k), el('span', { class: 'cal3-why' }, String(notes[k]))));
391
+ return el('section', { class: 'card cal3-lanes', 'data-cal3': 'lanes' }, el('h3', null, c3ic('filter'), 'In this range'), el('ul', { class: 'cal3-cl', role: 'list' }, ...rows, ...extra));
392
+ }
@@ -0,0 +1,103 @@
1
+ /* pm/c3-calpop.js — the calendar as a popup over any page of the console (briefing-brief PRD R7, R9).
2
+ *
3
+ * mountCalPopupTrigger(toolsHost) adds the calendar button to the top bar's tools, and binds the key C (once)
4
+ * openCalendarPopup(opener?) opens the full calendar (five zooms, no ribbon) in a dialog over the page
5
+ * closeCalendarPopup(back?) closes it; `back` (default true) returns the focus to where it was
6
+ * lastZoom() the zoom last used, "day" until the PM changes it
7
+ *
8
+ * The popup mounts calendar-views.js mountCalendarViews on a host of its own, so it never shares state with the Calendar tab
9
+ * (mountCalendarViews keys its state on the host element). Closing calls disposeCalendarViews on that host, which removes
10
+ * the view's own key handler. The dialog is data-shell="calpop", not "sheet", so the view's + and - keys keep working inside it;
11
+ * the Calendar tab's instance steps aside while the popup is open (calendar-views.js onKey).
12
+ * Not open on load. Escape, a press on the scrim and the close button close it. Tab stays inside. On a phone it is full screen.
13
+ * The zoom is kept in module state; localStorage only inside try/catch, as a convenience.
14
+ */
15
+ import { el } from './dom.js';
16
+ import { ensureC3Css, c3ic, c3ib, c3Phone } from './pm-work-view.js';
17
+ import { mountCalendarViews, disposeCalendarViews } from './calendar-views.js';
18
+
19
+ const ZOOMS = ['day', 'week', 'month', 'quarter', 'year'];
20
+ const KEY = 'pmos.c3.calpop.zoom';
21
+ const store = {
22
+ get() { try { return localStorage.getItem(KEY); } catch { return null; } },
23
+ set(v) { try { localStorage.setItem(KEY, v); } catch { /* no storage */ } },
24
+ };
25
+ let zoom = ZOOMS.includes(store.get()) ? store.get() : 'day';
26
+ let cur = null; // { wrap, host, opener, off, obs, stopPhone }
27
+ let keyBound = false;
28
+
29
+ export const lastZoom = () => zoom;
30
+ export const isCalendarPopupOpen = () => !!cur;
31
+
32
+ /** The zoom the view is on now: its pressed Day/Week/... button. */
33
+ function readZoom(host) {
34
+ const b = host.querySelector('[data-cv-view][aria-pressed="true"],[data-cv-view][aria-selected="true"],[data-cv-view][aria-current="true"]');
35
+ const v = b && b.getAttribute('data-cv-view');
36
+ return ZOOMS.includes(v) ? v : null;
37
+ }
38
+
39
+ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
40
+
41
+ export function closeCalendarPopup(back = true) {
42
+ if (!cur) return;
43
+ const c = cur; cur = null;
44
+ const z = readZoom(c.host); if (z) { zoom = z; store.set(z); }
45
+ document.removeEventListener('keydown', c.onKey, true);
46
+ if (c.obs) c.obs.disconnect();
47
+ if (c.stopPhone) c.stopPhone();
48
+ try { disposeCalendarViews(c.host); } catch { /* already gone */ }
49
+ c.wrap.remove();
50
+ if (back && c.opener && c.opener.isConnected && c.opener.focus) { try { c.opener.focus({ preventScroll: true }); } catch { /* not focusable any more */ } }
51
+ }
52
+
53
+ export function openCalendarPopup(opener) {
54
+ if (cur) return;
55
+ ensureC3Css();
56
+ const from = opener || (document.activeElement && document.activeElement !== document.body ? document.activeElement : document.querySelector('[data-c3pg="calendar"]'));
57
+ const close = () => closeCalendarPopup(true);
58
+ const host = el('div', { class: 'calpop-host', 'data-c3pg': 'calpop-host' });
59
+ const closeBtn = c3ib('Close', 'x', { title: 'Close (Esc)', 'data-c3pg': 'calpop-close', onclick: close }, 'sm');
60
+ const head = el('div', { class: 'sh2' }, el('span', { class: 'chips2' }, c3ic('cal'), el('b', null, 'Calendar')), el('span', { class: 'chips2' }, closeBtn));
61
+ const dlg = el('div', { class: 'sheet bs calpop', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Calendar', tabindex: '-1', 'data-c3pg': 'calpop' },
62
+ el('div', { class: 'grab' }), head, el('div', { class: 'sb' }, host));
63
+ const wrap = el('div', { class: 'c3 c3e c3pop', 'data-shell': 'calpop' }, el('div', { class: 'scrim', onclick: close }), dlg);
64
+ const stopPhone = c3Phone(wrap);
65
+ const onKey = (e) => {
66
+ if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close(); return; }
67
+ if (e.key !== 'Tab') return;
68
+ const items = [...dlg.querySelectorAll(FOCUSABLE)].filter((n) => n.offsetWidth > 0 || n.offsetHeight > 0 || n === document.activeElement);
69
+ if (!items.length) { e.preventDefault(); dlg.focus(); return; }
70
+ const at = items.indexOf(document.activeElement);
71
+ if (at < 0 || (e.shiftKey && at === 0) || (!e.shiftKey && at === items.length - 1)) { e.preventDefault(); items[e.shiftKey ? items.length - 1 : 0].focus(); }
72
+ };
73
+ document.addEventListener('keydown', onKey, true);
74
+ document.body.appendChild(wrap);
75
+ cur = { wrap, host, opener: from, onKey, stopPhone, obs: null };
76
+ mountCalendarViews(host, { view: zoom, scope: undefined });
77
+ /* remember the zoom as it changes, so a reload of the page also keeps it */
78
+ cur.obs = new MutationObserver(() => { const z = readZoom(host); if (z) { zoom = z; store.set(z); } });
79
+ cur.obs.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-pressed'] });
80
+ dlg.focus({ preventScroll: true });
81
+ }
82
+
83
+ const typing = (n) => !!(n && n.matches && n.matches('input,textarea,select,[contenteditable],[contenteditable="true"]'));
84
+ const otherSheet = () => !!document.querySelector('[data-shell="sheet"],[data-shell="preview"],[data-shell="palette"]');
85
+
86
+ function onC(e) {
87
+ if (e.key !== 'c' && e.key !== 'C') return;
88
+ if (e.ctrlKey || e.metaKey || e.altKey || e.defaultPrevented || e.repeat) return;
89
+ if (cur) return; // open already: Escape closes it
90
+ if (typing(document.activeElement) || typing(e.target) || otherSheet()) return;
91
+ e.preventDefault();
92
+ openCalendarPopup();
93
+ }
94
+
95
+ /** The calendar button, last before the search button; and the key C. Call once, with the top bar's tools element. */
96
+ export function mountCalPopupTrigger(toolsHost) {
97
+ if (!keyBound) { keyBound = true; document.addEventListener('keydown', onC, true); }
98
+ if (!toolsHost || toolsHost.querySelector('[data-c3pg="calendar"]')) return null;
99
+ const btn = c3ib('Calendar (C)', 'cal', { 'aria-haspopup': 'dialog', 'data-c3pg': 'calendar', onclick: (e) => openCalendarPopup(e.currentTarget) });
100
+ const search = toolsHost.querySelector('[data-c3pg="search"]');
101
+ if (search) toolsHost.insertBefore(btn, search); else toolsHost.append(btn);
102
+ return btn;
103
+ }
@@ -0,0 +1,54 @@
1
+ /* pm/c3-demo-brief-page.js — DEMO ONLY (/c3.html?demo=1): the Briefing's Today agenda and the own-item rows of Needs you, in the real shape of
2
+ * GET /api/brief-page (lib/routes-brief-page.js), for the demo day (Thu 8 Oct 2026, 08:12), with the demo's own people and initiatives.
3
+ * Today is the five meetings of the demo calendar (c3-demo-data.js MEET). Needs you gets two items that are overdue, one due today, and two outside-PMOS items
4
+ * (one with evidence: a meeting with Dara is booked today). Decisions are the demo briefing's own needs (c3-demo-data.js NEEDS), not repeated here.
5
+ * Writes: the pm-items routes the rows call (done, notyet, remove, reopen, undo) are answered in memory, and every one is kept in window.__c3demoWrites
6
+ * ({ path, body }) so a test can see what the page asked to write. Real mode never loads this file. */
7
+ const iso = (h, m) => new Date(2026, 9, 8, h, m).toISOString();
8
+ const TODAY = [
9
+ { id: 'm1', title: 'Standup', start: iso(9, 30), end: iso(10, 0), people: ['Dara', 'Lee', 'Sam'], initiative: 'Checkout v2', prepared: [], before: [] },
10
+ { id: 'm2', title: 'Checkout sync', start: iso(10, 30), end: iso(11, 15), people: ['Priya', 'Dara'], initiative: 'Checkout v2',
11
+ prepared: [{ kind: 'prep', text: 'Prep note: sandbox keys are 2 days late; Jira says 24 Oct, the PRD says 31 Oct.' }, { kind: 'last-note', text: 'Last notes: Checkout sync (2026-10-05)' }],
12
+ before: [{ id: 'i2', title: 'Confirm the 1 Oct numbers before the 10:30 update', due: '2026-10-08' }] },
13
+ { id: 'm3', title: 'Customer interview', start: iso(13, 0), end: iso(14, 0), people: ['Marta'], initiative: 'Returns experience',
14
+ prepared: [{ kind: 'prep', text: 'Interview guide draft is in Review.' }], before: [{ id: 'i6', title: 'Review the 13:00 interview guide', due: '2026-10-08' }] },
15
+ { id: 'm4', title: 'Sprint planning', start: iso(14, 0), end: iso(15, 0), people: ['Dara', 'Priya', 'Sam'], initiative: 'Checkout v2', prepared: [],
16
+ before: [{ id: 'i1', title: 'Cut Checkout v2 to 3 core flows to hold 24 Oct?', due: '2026-10-08' }] },
17
+ { id: 'm5', title: 'Leadership update', start: iso(16, 30), end: iso(17, 0), people: ['Priya', 'Marta'], initiative: null,
18
+ prepared: [{ kind: 'last-note', text: 'Last notes: Leadership update (2026-10-01)' }], before: [] },
19
+ ];
20
+ const NEEDS = [
21
+ { key: 'item:bp-od1', kind: 'overdue', id: 'bp-od1', headline: 'Send Priya the revised Checkout v2 date', due: '2026-10-06', days_late: 2, priority: 'P1', person: 'Priya', offline: false, evidence: null },
22
+ { key: 'item:bp-od2', kind: 'overdue', id: 'bp-od2', headline: 'Ask Lee to book the design vendor call', due: '2026-10-07', days_late: 1, priority: 'P2', person: 'Lee', offline: true, evidence: null },
23
+ { key: 'item:bp-dt1', kind: 'due-today', id: 'bp-dt1', headline: 'Send Lee the pricing-page feedback', due: '2026-10-08', days_late: 0, priority: 'P2', person: null, offline: false, evidence: null },
24
+ { key: 'item:bp-of1', kind: 'offline', id: 'bp-of1', headline: 'Talk to Dara about the flow 4 estimate', due: null, days_late: 0, priority: 'P2', person: 'Dara', offline: true,
25
+ evidence: { text: 'a meeting with Dara is booked Thu 14:00', event_id: 'm4' } },
26
+ ];
27
+
28
+ let gone = {};
29
+ export function installBriefPage() {
30
+ if (window.__c3demoBriefPage) return; window.__c3demoBriefPage = true;
31
+ window.__c3demoWrites = [];
32
+ const prev = window.fetch.bind(window);
33
+ const json = (b) => new Response(JSON.stringify(b), { status: 200, headers: { 'content-type': 'application/json' } });
34
+ window.fetch = (input, init) => {
35
+ try {
36
+ const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
37
+ const u = new URL(raw, location.origin);
38
+ const method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
39
+ if (u.origin === location.origin) {
40
+ if (method === 'GET' && u.pathname === '/api/brief-page') return Promise.resolve(json({ ok: true, date: '2026-10-08', today: TODAY.map((m) => ({ ...m, minutes: (Date.parse(m.end) - Date.parse(m.start)) / 60000, state: Date.parse(m.end) <= Date.now() ? 'past' : 'later' })).map((m, i, a) => ({ ...m, state: m.state === 'past' ? 'past' : (a.findIndex((x) => x.state !== 'past' && Date.parse(x.start) > Date.now()) === i ? 'next' : 'later') })), needs: NEEDS.filter((n) => !gone[n.id]) }));
41
+ const m = /^\/api\/pm-items\/(done|notyet|remove|reopen|undo)$/.exec(u.pathname);
42
+ if (method !== 'GET' && m) {
43
+ let b = {}; try { b = init && init.body ? JSON.parse(init.body) : {}; } catch { b = {}; }
44
+ if (NEEDS.some((n) => n.id === b.id)) {
45
+ window.__c3demoWrites.push({ path: u.pathname, body: b });
46
+ if (m[1] === 'reopen' || m[1] === 'undo') delete gone[b.id]; else gone[b.id] = m[1];
47
+ return Promise.resolve(json({ ok: true }));
48
+ }
49
+ }
50
+ }
51
+ } catch { /* an unreadable request goes on as it is */ }
52
+ return prev(input, init);
53
+ };
54
+ }