create-pmos 0.179.0 → 0.181.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 (55) 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-driver-test.js +6 -1
  7. package/workspace/c3-report-setup-test.js +190 -0
  8. package/workspace/c3-reports-test.js +30 -97
  9. package/workspace/guide/document-sheet.md +14 -0
  10. package/workspace/guide/index.md +3 -2
  11. package/workspace/guide/initiative-driver.md +2 -2
  12. package/workspace/guide/reports-dashboards.md +17 -28
  13. package/workspace/guide/reports-page.md +10 -5
  14. package/workspace/guide/reports-set-up.md +56 -0
  15. package/workspace/guide/reports.md +13 -1
  16. package/workspace/guide/settings.md +11 -2
  17. package/workspace/lib/doc-sync.js +28 -6
  18. package/workspace/lib/docs-cli.js +7 -11
  19. package/workspace/lib/four-panel.js +1 -1
  20. package/workspace/lib/headless.js +21 -2
  21. package/workspace/lib/report-checks.js +103 -0
  22. package/workspace/lib/report-cli.js +89 -4
  23. package/workspace/lib/report-export.js +347 -0
  24. package/workspace/lib/report-finder.js +156 -0
  25. package/workspace/lib/report-needs.js +1 -1
  26. package/workspace/lib/report-setups.js +743 -0
  27. package/workspace/lib/route-modules.js +1 -0
  28. package/workspace/lib/routes-c3-reports.js +35 -17
  29. package/workspace/lib/routes-doc-health.js +15 -1
  30. package/workspace/lib/routes-report-setups.js +368 -0
  31. package/workspace/lib/schedule-logic.js +5 -2
  32. package/workspace/package-lock.json +2 -2
  33. package/workspace/package.json +1 -1
  34. package/workspace/public/pm/c3-demo-rep2.js +15 -0
  35. package/workspace/public/pm/c3-demo-rs.js +281 -0
  36. package/workspace/public/pm/c3-demo.js +1 -1
  37. package/workspace/public/pm/c3-doc-formats.js +152 -0
  38. package/workspace/public/pm/c3-doc-sheet.js +115 -25
  39. package/workspace/public/pm/c3-ini-ui.js +15 -1
  40. package/workspace/public/pm/c3-initiatives.js +3 -6
  41. package/workspace/public/pm/c3-quiet.js +2 -2
  42. package/workspace/public/pm/c3-report-print.css +20 -0
  43. package/workspace/public/pm/c3-report-setup.css +62 -0
  44. package/workspace/public/pm/c3-report-setup.js +260 -0
  45. package/workspace/public/pm/c3-report-store.js +208 -0
  46. package/workspace/public/pm/c3-reports.css +4 -0
  47. package/workspace/public/pm/c3-reports.js +47 -181
  48. package/workspace/public/pm/c3-settings.js +4 -3
  49. package/workspace/public/pm/c3-shell.js +15 -31
  50. package/workspace/server.js +66 -5
  51. package/workspace/skills/doc-sync.skill +17 -8
  52. package/workspace/skills/report-find.skill +75 -0
  53. package/workspace/skills/report-setup.skill +67 -0
  54. package/workspace/test-files.json +10 -0
  55. package/workspace/test-lists/report-workspace-ui.json +5 -0
@@ -0,0 +1,281 @@
1
+ /* pm/c3-demo-rs.js — DEMO answers for the report workspace (initiative report-workspace) on /c3.html?demo=1. Real mode never loads this file.
2
+ *
3
+ * GET /api/report-setups setups, findings, asks, finder (the contract shapes)
4
+ * POST /api/report-setups/ask|change|cancel|setup|dismiss|pause|resume|from-dashboard|finder
5
+ * GET /api/report-documents one entry per cycle document; each is a real markdown text served through c3-demo-rep2.js (seedDoc)
6
+ * GET /api/report/draft, POST /api/report/issue the existing report routes the Issue button uses
7
+ * GET /api/report-export a small file per format (csv holds the real tables of the text)
8
+ * GET /api/docs the document list of the earlier wrapper plus the copies of the report documents
9
+ * POST /api/docs/create-copy|check|sync change the copy state in memory
10
+ *
11
+ * The mockup's states all exist here: three findings (Likely, Likely, Not sure), a draft with 2 open proposals and 1 rejected, a draft
12
+ * whose copy is not created, a draft PMOS is ahead of (edit a draft that has a copy), an issued document, a PDF with no copy, one-off
13
+ * and scheduled documents, and an ask that runs for 1.5 seconds before it answers. An ask is answered from its words: a TSC ask gets the TSC
14
+ * proposal, an OOH or review ask the review, a progress or biweekly ask the biweekly update, anything else a small proposal with no earlier
15
+ * copy. Nothing is saved anywhere and nothing is sent. Installed AFTER c3-demo-rep2.js, so this wrapper answers first. */
16
+ import { seedDoc, seedProposal, reviseDoc, docText } from './c3-demo-rep2.js';
17
+ import { scheduleLabel, nextDraftDate } from './c3-report-store.js';
18
+ import { FORMATS } from './c3-doc-formats.js';
19
+
20
+ const json = (b, status) => new Response(JSON.stringify(b), { status: status || 200, headers: { 'content-type': 'application/json' } });
21
+ const iso = (d) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
22
+ const PATH = 'planning/reports/'; // <state>/reports/<cadence>/<slot>.md, as the drafter writes it
23
+ const AT = '2026-10-12T21:00:00.000Z';
24
+ let seq = 0;
25
+
26
+ /* ── the proposals PMOS found ── */
27
+ const E = (kind, text, when) => ({ kind, text, when });
28
+ const FIND = {
29
+ rs1: { id: 'rs1', confidence: 'likely', next: '2026-10-21', why: 'You send this every 2 weeks, the day before a standing session that has no other prep.',
30
+ evidence: [E('mail', '6 sends, every 2 weeks'), E('calendar', 'eShop & MySPH Progress Update, Thu 16:00'), E('drive', '16 earlier copies')],
31
+ proposal: { name: 'MySPH & Identity Platform biweekly update', purpose: 'Tell the eShop & MySPH progress session what shipped, what comes next and what needs a decision.', audience: 'The attendees of the progress session (28 people)', source: 'work', output: 'google-slides',
32
+ schedule: { cadence: 'biweekly', day: 'wed', time: '15:00', anchor: 'the day before the Thursday session' }, sources: ["Jira (IDPF)", 'Initiatives and releases in PMOS', 'Product metrics', "Last cycle's update"],
33
+ sections: ['What shipped', 'Coming next 2 weeks', 'Decisions needed', 'Risk watch', 'Product metrics'], learned_from: { name: 'Your 26 Mar update, the one-page summary', why: 'It is the version people replied to, and you changed it least afterwards.' },
34
+ fixes: ['Dates written in full, never copied from the last cycle', 'Decisions and Risk watch always shown, even when empty', 'One metric definition for login and signup'],
35
+ found: [E('mail', 'Subject "MySPH & Identity Platform biweekly update", 6 sends since July, to 28 people', 'last sent Wed 7 Oct'), E('calendar', '"eShop & MySPH Progress Update", Thu 16:00, every 2 weeks, you attend', 'next Thu 22 Oct'), E('drive', 'One deck with 16 earlier copies of this update', 'newest Wed 7 Oct')] } },
36
+ rs2: { id: 'rs2', confidence: 'likely', next: '2026-10-16', why: 'A monthly template is shared with "drafts due before pre-TSC", and you fill it in.',
37
+ evidence: [E('calendar', 'TSC #29 on 29 Oct, Pre-TSC on 19 Oct, monthly'), E('slack', '#digital-product, template shared each month'), E('drive', '7 earlier copies')],
38
+ proposal: { name: 'TSC Digital input', purpose: 'Give the Technical Steering Committee the digital view: what went well, what did not, what you tested.', audience: 'The TSC, through the #digital-product owners', source: 'work', output: 'google-sheets',
39
+ schedule: { cadence: 'monthly', dayOfMonth: 18, time: '09:00', anchor: 'a week before the TSC' }, sources: ['A/B test results', 'Initiatives and releases in PMOS', "Last TSC's actions"],
40
+ sections: ['Metrics', 'Highlights and lowlights', 'A/B tests and learnings', 'Actions from last TSC'], learned_from: { name: 'Your Sep Digital input', why: 'It is the newest one and it matches the template in Slack.' },
41
+ fixes: ['Dates written in full, never copied from the last cycle', 'Actions from last TSC always shown, even when empty', 'Test results quote the same metric definition as the dashboards'],
42
+ found: [E('calendar', '"TSC #29" and "Pre-TSC", monthly', 'TSC Thu 29 Oct, Pre-TSC Mon 19 Oct'), E('slack', '#digital-product: template shared each month with "drafts due before pre-TSC"', '3 months in a row'), E('drive', '7 earlier copies of your Digital input', 'newest 21 Sep')] } },
43
+ rs3: { id: 'rs3', confidence: 'not-sure', next: null, why: 'You organise the series and one page changes before it. That may be a report, or only notes.',
44
+ evidence: [E('calendar', 'a monthly series you organise'), E('confluence', 'one running page, updated irregularly')],
45
+ proposal: { name: 'Monthly OOH x Digital review', purpose: 'Keep the OOH and Digital teams on one view of the month. PMOS is guessing the purpose.', audience: 'The attendees of the review', source: 'work', output: 'confluence',
46
+ schedule: { cadence: 'monthly', time: '09:00', anchor: 'day to be confirmed' }, sources: ['Calendar series', 'The running Confluence page'], sections: ['Summary', 'Spend and reach', 'Decisions needed', 'Actions from last review'],
47
+ learned_from: { name: 'The running Confluence page, latest edit', why: 'There are no earlier copies, so the structure is a guess.' },
48
+ fixes: ['Dates written in full, never copied from the last cycle', 'Nothing is drafted until you confirm the sections'],
49
+ found: [E('calendar', '"OOH x Digital review", monthly series, you organise', '5 meetings so far'), E('confluence', 'One running page, edited on irregular days before the meeting', 'last edited 30 Sep')] } },
50
+ };
51
+ const clone = (x) => JSON.parse(JSON.stringify(x));
52
+ let findings = ['rs1', 'rs2', 'rs3'].map((k) => clone(FIND[k]));
53
+ const dismissed = new Set();
54
+ let asks = [];
55
+ let finder = { cadence: 'weekly', sources: { calendar: true, slack: true, mail: false, drive: true }, last_run: '2026-10-12', next_run: '2026-10-19' };
56
+
57
+ /* ── the text of a cycle document ── */
58
+ const SHIP_OLD = '- Webhook retries documented, Mon 28 Sep\n- Sandbox keys beta, Thu 1 Oct\n- Rate-limit headers on every response, Tue 6 Oct';
59
+ const SHIP_NEW = '- Self-serve sandbox keys for every partner, Tue 6 Oct\n- Clearer login error messages, Thu 8 Oct\n- Retry time on Partner API 429 responses, Wed 14 Oct';
60
+ const METRICS = '- **97.4%** Login success, +0.6 pts\n- **71.2%** Signup completion, -1.1 pts\n- **461** Active integrations, +11.9%';
61
+ const METRICS_SRC = '- **97.4%** Login success, +0.6 pts\n- **71.2%** Signup completion, -1.1 pts (source: signup funnel dashboard)\n- **461** Active integrations, +11.9%';
62
+ const sec = (h, body) => '## ' + h + '\n' + body + '\n';
63
+ const title = (name, line, prepared) => '# ' + name + '\n\n' + line + '\n\n' + prepared + '\n';
64
+ function textOf(rid, o) {
65
+ const st = o.issued ? 'Issued.' : 'Draft, not issued.';
66
+ if (rid === 'rs1') return [title('MySPH & Identity Platform biweekly update', 'Cycle ending ' + o.end, 'Prepared by Wawan, IDPF. ' + st),
67
+ sec('What shipped', o.issued ? SHIP_OLD : SHIP_NEW), sec('Coming next 2 weeks', o.issued ? '- Self-serve sandbox keys for every partner, Tue 13 Oct\n- Login error messages, Thu 15 Oct' : '- Onboarding v2 beta opens, Mon 26 Oct\n- Webhook retry design review, Wed 28 Oct\n- Self-serve onboarding checkpoint, Fri 30 Oct'),
68
+ sec('Decisions needed', o.issued ? '- Pick the sandbox key format by Fri 9 Oct (owner: Ana Putri)' : '- Approve the scope cut for Onboarding v2 beta, by Fri 23 Oct (owner: Ana Putri)\n- Choose the rate-limit approach: Option A recommended'),
69
+ sec('Risk watch', '- Activation down 6% week on week, trial cohort only'), sec('Product metrics', o.srcOk === false ? METRICS : METRICS_SRC)].join('\n');
70
+ if (rid === 'rs2') return [title('TSC Digital input', 'Month ' + o.end, 'Prepared by Wawan, Digital. ' + st),
71
+ sec('Metrics', '| Metric | Definition | Sep | Oct | Change |\n|---|---|---|---|---|\n| Login success rate | Successful sign-ins / attempts, bots excluded | 96.8% | 97.4% | +0.6 pts |\n| Signup completion | Accounts created / signup starts | 72.3% | 71.2% | -1.1 pts |\n| Activation, day 7 | New accounts with a first API call in 7 days | 46.2% | 43.4% | -2.8 pts |\n| Active partner integrations | Partners with a call in the last 30 days | 412 | 461 | +11.9% |\n| Partner API 429s per 1,000 calls | 429 responses / calls x 1,000 | 2.1 | 6.4 | +4.3 |'),
72
+ sec('Highlights and lowlights', '- Highlight: sandbox keys at signup lifted first calls by 6 pts\n- Highlight: 461 active integrations, +11.9%\n- Lowlight: activation down 2.8 pts, trial cohort only\n- Lowlight: Partner API 429s tripled on two partners'),
73
+ sec('A/B tests and learnings', '| Test | Variant | Result | Decision |\n|---|---|---|---|\n| Sandbox key at signup | Key shown on sign-up | +6 pts first call | Keep |\n| Login error copy | Plain words | +0.6 pts success | Keep |\n| Onboarding checklist | Short | Flat | Stop |'),
74
+ sec('Actions from last TSC', '| Action | Owner | Due | Status |\n|---|---|---|---|\n| Publish the rate-limit guide | Ana Putri | Fri 9 Oct | Done |\n| Share the activation cohort cut | Budi | Fri 16 Oct | Open |')].join('\n');
75
+ if (rid === 'rs3') return [title('Monthly OOH x Digital review', 'Month ' + o.end, 'Prepared by Wawan. ' + st),
76
+ sec('Summary', 'The October outdoor run for MySPH sign-up ended Sun 11 Oct. Digital sign-ups rose in the three cities that had posters.'),
77
+ sec('Spend and reach', '- Outdoor spend: 92% of plan\n- Posters ran Mon 28 Sep to Sun 11 Oct in 3 cities\n- Digital reach in those cities: up 14% on the previous run'),
78
+ sec('Decisions needed', '- Repeat the run in November, or move the budget to search\n- One name for the campaign link, so both teams count the same sign-ups'), sec('Actions from last review', '- Share the poster map with Digital, done Wed 7 Oct\n- Agree one sign-up definition, open')].join('\n');
79
+ if (rid === 'rg0') return [title('Biweekly report (PMOS)', 'Cycle ending ' + o.end, 'Prepared by Wawan. ' + st),
80
+ sec('Progress', '- Tokenisation 1.4 review accepted with findings, Tue 13 Oct\n- Self-serve onboarding v2 moved to Build, Thu 8 Oct\n- Partner API rate-limit options paper in progress'),
81
+ sec('Decisions needed', '- Choose the rate-limit approach before the Mon 12 Oct review\n- Approve the scope cut for Onboarding v2 beta'), sec('Risk watch', '- Paper not finished before the review\n- Three tasks overdue in Work'), sec('Next 2 weeks', '- Onboarding v2 beta opens, Mon 26 Oct')].join('\n');
82
+ if (rid === 'rgp' || rid === 'rgi' || rid === 'rgo') {
83
+ const name = o.name; const all = [['North star', 'North-star metric on its plan: 71 of 100 on the year line'], ['Quarter outcomes', 'Two of four key results on track, one at risk'], ['Early signals', 'Activation drifting; checkout conversion steady'], ['Delivery against promise', 'Four of six promised dates hold'], ['Bets and their checkpoints', 'Two bets persevere, one to pivot on 30 Oct']];
84
+ const use = rid === 'rgi' ? all.slice(1, 4) : all;
85
+ return [title(name, 'Snapshot of ' + o.end, 'Prepared by Wawan. ' + st), ...use.map(([h, l]) => sec(h, '- ' + l + '\n- Read from the dashboard on ' + o.end))].join('\n');
86
+ }
87
+ /* any other set-up: the proposal's sections with a line of what PMOS will read */
88
+ return [title(o.name, 'Cycle ending ' + o.end, 'Prepared by Wawan. ' + st), ...(o.sections || ['Summary']).map((h) => sec(h, '- PMOS fills this in from ' + ((o.sources || [])[0] || 'your sources') + ' on the day.'))].join('\n');
89
+ }
90
+ const front = (name, cyc, output) => '---\ntype: Report\ntitle: ' + name + ' · ' + cyc + '\ncycle: ' + cyc + '\nformat: ' + ((FORMATS[output] || {}).l || 'Google Doc') + '\n---\n\n';
91
+ const healthOf = (text, rid, srcOk) => {
92
+ const need = ['rs1', 'rg0'].includes(rid) ? ['Decisions needed', 'Risk watch'] : rid === 'rs3' ? ['Decisions needed'] : [];
93
+ const miss = need.filter((n) => !new RegExp('^## ' + n + '$', 'm').test(text));
94
+ const C = [{ id: 'src', name: 'Numbers have a source', pass: srcOk !== false, reason: srcOk !== false ? 'Every number links to a source.' : 'The Signup completion row has no source.', ...(srcOk === false ? { fix: 'Add the source of signup completion' } : {}) },
95
+ { id: 'dates', name: 'Dates written in full', pass: true, reason: 'Every date has a weekday, a day and a month.' },
96
+ { id: 'must', name: need.length ? need.join(' and ') + ' present' : 'Required sections present', pass: !miss.length, reason: need.length ? (miss.length ? miss.join(', ') + ' missing.' : 'Shown even when empty.') : 'This report has no required sections.' }];
97
+ return { passed: C.filter((c) => c.pass).length, total: C.length, checks: C };
98
+ };
99
+
100
+ /* ── set-ups and documents ── */
101
+ const KIND = { 'google-slides': 'google-slides', 'google-doc': 'google', 'google-sheets': 'google-sheets', confluence: 'confluence', pdf: null };
102
+ const TARGET = { 'google-slides': 'docs.google.com/presentation/d/', 'google': 'docs.google.com/document/d/', 'google-sheets': 'docs.google.com/spreadsheets/d/', confluence: 'acme.atlassian.net/wiki/spaces/PM/pages/' };
103
+ const SETUPS = [];
104
+ const DOCS = []; // { path, id, setup, name, slot, state, source, output, made_by, v, copy, rid, srcOk, at }
105
+ function addSetup(def) { SETUPS.push({ status: 'active', ...def }); return SETUPS[SETUPS.length - 1]; }
106
+ function addDoc(d) {
107
+ const rec = { v: 2, copy: { kind: KIND[d.output], state: 'none', url: null }, ...d, path: PATH + (d.setup || 'one-off-' + d.id.replace(/[^a-z0-9]/g, '')) + '/' + (/^\d{4}-\d{2}$/.test(d.slot) ? d.slot + '-01' : d.slot) + '.md' };
108
+ const text = front(d.name, d.endText, d.output) + textOf(d.rid, { end: d.endText, issued: d.state === 'issued', srcOk: d.srcOk, name: d.name, sections: d.sections, sources: d.sources });
109
+ const revs = [{ by: 'claude-code/session', why: d.drafted || 'Drafted by PMOS from your earlier copies', text, via: 'schedule', at: '2026-10-08T08:12:00.000Z' }];
110
+ if (rec.v >= 2) revs.push({ by: 'human:wawan', why: 'Clearer for the readers.', text: text.replace(/\n$/, '') + '\n', at: '2026-10-08T09:30:00.000Z' });
111
+ seedDoc(rec.path, revs, healthOf(text, d.rid, d.srcOk));
112
+ DOCS.push(rec); return rec;
113
+ }
114
+ function seedAll() {
115
+ const mk = (id, name, extra) => addSetup({ id, name, ...extra });
116
+ mk('rgp', 'Portfolio dashboard snapshot', { purpose: 'A snapshot of the portfolio dashboard for leadership.', audience: 'Leadership', source: 'dashboard:portfolio', output: 'pdf', schedule: { cadence: 'monthly', dayOfMonth: 1, time: '08:00' }, next_draft: '2026-11-01' });
117
+ mk('rgi', 'IDPF dashboard snapshot', { purpose: 'A snapshot of the IDPF board dashboard.', audience: 'Team', source: 'dashboard:board:shop', output: 'pdf', schedule: { cadence: 'monthly', dayOfMonth: 1, time: '08:00' }, next_draft: '2026-11-01' });
118
+ mk('rg0', 'Biweekly report (PMOS)', { purpose: 'The progress report you send every other Friday.', audience: 'Leadership', source: 'work', output: 'pdf', schedule: { cadence: 'biweekly', day: 'fri', time: '14:00' }, next_draft: '2026-10-16' });
119
+ const base = { source: 'work', made_by: 'schedule' };
120
+ addDoc({ ...base, id: 'rgp-c', setup: 'rgp', rid: 'rgp', name: 'Portfolio dashboard snapshot', slot: '2026-10-09', state: 'draft', output: 'pdf', source: 'dashboard:portfolio', endText: 'Fri 9 Oct 2026', drafted: 'Drafted by schedule' });
121
+ addDoc({ ...base, id: 'rgi-c', setup: 'rgi', rid: 'rgi', name: 'IDPF dashboard snapshot', slot: '2026-10-11', state: 'draft', output: 'pdf', source: 'dashboard:board:shop', made_by: 'you', endText: 'Sun 11 Oct 2026', drafted: 'Drafted by PMOS from the dashboard' });
122
+ addDoc({ ...base, id: 'rgo-1', setup: null, rid: 'rgo', name: 'Leadership update, quarter outcomes', slot: '2026-10-02', state: 'issued', output: 'pdf', source: 'dashboard:portfolio', made_by: 'you', endText: 'Fri 2 Oct 2026', drafted: 'Drafted by PMOS from the dashboard' });
123
+ addDoc({ ...base, id: 'rg0-c', setup: 'rg0', rid: 'rg0', name: 'Biweekly report (PMOS)', slot: '2026-10-16', state: 'draft', output: 'pdf', endText: 'Fri 16 Oct 2026' });
124
+ addDoc({ ...base, id: 'rg0-p1', setup: 'rg0', rid: 'rg0', name: 'Biweekly report (PMOS)', slot: '2026-10-02', state: 'issued', output: 'pdf', endText: 'Fri 2 Oct 2026' });
125
+ addDoc({ ...base, id: 'rg0-p2', setup: 'rg0', rid: 'rg0', name: 'Biweekly report (PMOS)', slot: '2026-09-18', state: 'issued', output: 'pdf', endText: 'Fri 18 Sep 2026' });
126
+ }
127
+ /* the documents a set-up of a known report starts with: the draft (and the issued cycles before it) */
128
+ function startDocs(s, F) {
129
+ const rid = F && FIND[F.id] ? F.id : '';
130
+ const known = { rs1: [['2026-10-21', 'draft', 'Wed 21 Oct 2026'], ['2026-10-07', 'issued', 'Wed 7 Oct 2026'], ['2026-09-23', 'issued', 'Wed 23 Sep 2026'], ['2026-09-09', 'issued', 'Wed 9 Sep 2026']],
131
+ rs2: [['2026-10', 'draft', 'Oct 2026'], ['2026-09', 'issued', 'Sep 2026']], rs3: [['2026-10', 'draft', 'Oct 2026'], ['2026-09', 'issued', 'Sep 2026']] }[rid];
132
+ const out = [];
133
+ const list = known || [[s.next_draft || iso(new Date()), 'draft', s.next_draft ? 'Cycle of ' + s.next_draft : 'the first cycle']];
134
+ list.forEach(([slot, state, endText], i) => {
135
+ const d = addDoc({ id: s.id + (i ? '-p' + i : '-c'), setup: s.id, rid: rid || s.id, name: s.name, slot, state, output: s.output, source: s.source, made_by: known ? (state === 'issued' ? 'schedule' : 'you') : (s.source !== 'work' ? 'schedule' : 'agent'), endText,
136
+ srcOk: rid === 'rs1' || rid === 'rs2' ? state === 'issued' : true, sections: (s.sections || []), sources: s.sources, drafted: state === 'draft' ? 'Sample draft made when you set up' : 'Drafted by schedule' });
137
+ if (rid && state === 'issued') d.copy = { kind: KIND[s.output], state: 'in-step', url: null };
138
+ if (rid === 'rs3' && state === 'draft') d.copy = { kind: 'confluence', state: 'in-step', url: null };
139
+ out.push(d);
140
+ });
141
+ const cur = out[0];
142
+ if (rid === 'rs1') {
143
+ const P = (id, t, o) => { const after = o.build(docText(cur.path)); seedProposal(id, cur.path, { prompt: o.prompt, after, state: o.state, why: o.why }); };
144
+ P('P-1', 'Risk watch', { prompt: 'Risk watch: add the Partner API 429 risk. Jira IDPF-88, opened Fri 9 Oct, is not in Risk watch yet.', build: (t) => t.replace('- Activation down 6% week on week, trial cohort only', '- Activation down 6% week on week, trial cohort only\n- Partner API 429 errors up 3x on two partners (Jira IDPF-88)') });
145
+ P('P-2', 'Product metrics', { prompt: 'Product metrics: add the source of signup completion. The check "Numbers have a source" fails.', build: (t) => t.replace(METRICS, METRICS_SRC) });
146
+ P('P-0', 'Releases', { state: 'rejected', why: 'Rejected by Wawan, Mon 12 Oct: Releases are in Coming next 2 weeks.', prompt: 'Add a Releases slide', build: (t) => t + '\n## Releases\n- Release 2.14, Tue 20 Oct\n' });
147
+ }
148
+ return out;
149
+ }
150
+ const subCopy = (s) => { const L = DOCS.filter((d) => d.setup === s.id).sort((a, b) => String(b.slot).localeCompare(String(a.slot)))[0]; return L ? { ...L.copy } : { kind: KIND[s.output], state: 'none', url: null }; };
151
+ const setupView = (s) => { const mine = DOCS.filter((d) => d.setup === s.id); const iss = mine.filter((d) => d.state === 'issued').map((d) => d.slot).sort().pop();
152
+ const { sections, sources, found, fixes, learned_from, ...rest } = s;
153
+ return { ...rest, label: scheduleLabel(s.schedule) + (s.status === 'paused' ? ' (paused)' : ''), last_issued: iss || null, cycles: mine.length, copy: subCopy(s) }; };
154
+ const docView = (d) => { const { rid, srcOk, endText, drafted, sections, sources, at, ...rest } = d; return { ...rest }; };
155
+
156
+ /* ── asks: a proposal made from the words, ready 1.5 s after the press ── */
157
+ function proposalFromText(text) {
158
+ const t = String(text || '');
159
+ const key = /tsc|steering|digital input/i.test(t) ? 'rs2' : /\booh\b|outdoor/i.test(t) ? 'rs3' : /idpf|mysph|biweekly|progress|identity|thursday/i.test(t) ? 'rs1' : '';
160
+ if (key) { const p = clone(FIND[key].proposal); p.learned_from = p.learned_from; return p; }
161
+ const nm = t.replace(/^(set up|make|draft|create)( me)?( a| my| the)?\s+/i, '').replace(/\s+/g, ' ').trim();
162
+ const name = (nm.charAt(0).toUpperCase() + nm.slice(1)).slice(0, 70) || 'New report';
163
+ return { name, purpose: 'Your words: ' + t.trim(), audience: 'The people you send it to (PMOS asks you on the first draft)', source: 'work', output: 'google-doc', schedule: { cadence: 'monthly', time: '09:00', anchor: 'day to be confirmed' },
164
+ sources: ['Initiatives and releases in PMOS'], sections: ['Summary', 'Decisions needed', 'Risk watch'], learned_from: null, fixes: ['Dates written in full, never copied from the last cycle'], found: [] };
165
+ }
166
+ function applyChange(p, text) {
167
+ const t = String(text || ''); const days = { mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri' };
168
+ const m = /\b(mon|tue|wed|thu|fri)[a-z]*\b/i.exec(t); if (m) { p.schedule = { ...p.schedule, day: days[m[1].toLowerCase()] }; }
169
+ if (/release/i.test(t) && !p.sections.includes('Release list')) { const i = p.sections.indexOf('Coming next 2 weeks'); p.sections.splice(i >= 0 ? i + 1 : p.sections.length, 0, 'Release list'); }
170
+ const a = /\badd (?:a |the )?([a-z][a-z ]{2,30})/i.exec(t); if (a && !/release/i.test(a[1]) && !p.sections.includes(a[1].trim())) p.sections.push(a[1].trim().replace(/^./, (c) => c.toUpperCase()));
171
+ return p;
172
+ }
173
+ const askView = (a) => { const ready = a.state === 'running' && performance.now() - a.t0 >= 1500; if (ready) { a.state = a.fail ? 'failed' : 'ready'; if (a.pending) { a.proposal = applyChange(a.proposal, a.pending); a.changed = true; a.pending = null; } }
174
+ return { id: a.id, text: a.text, state: a.state, changed: !!a.changed, proposal: a.state === 'running' ? null : a.proposal, error: a.state === 'failed' ? 'the run ended before it made a proposal' : '' }; };
175
+
176
+ /* ── the routes ── */
177
+ function setupFrom(b) {
178
+ let P = b.proposal ? clone(b.proposal) : null; let F = null; let askRec = null;
179
+ if (b.finding) { F = findings.find((f) => f.id === b.finding); if (!F) return json({ ok: false, error: 'no such finding' }, 404); P = P || clone(F.proposal); }
180
+ if (b.ask) { askRec = asks.find((a) => a.id === b.ask); if (!askRec) return json({ ok: false, error: 'no such ask' }, 404); P = P || clone(askRec.proposal); }
181
+ if (!P) return json({ ok: false, error: 'a proposal is required' }, 400);
182
+ const id = F ? F.id : 'ask' + (++seq);
183
+ if (SETUPS.some((s) => s.id === id)) return json({ ok: false, error: 'already set up' }, 409);
184
+ const next = F && F.next !== undefined ? F.next : (P.schedule && (P.schedule.day || P.schedule.dayOfMonth) ? iso(nextDraftDate(P.schedule) || new Date()) : null);
185
+ const s = addSetup({ id, name: P.name, purpose: P.purpose, audience: P.audience, source: P.source || 'work', output: P.output, schedule: P.schedule, next_draft: next, sections: P.sections, sources: P.sources });
186
+ startDocs(s, F);
187
+ if (F) findings = findings.filter((f) => f.id !== F.id);
188
+ if (askRec) asks = asks.filter((a) => a.id !== askRec.id);
189
+ return json({ ok: true, setup: setupView(s), first_draft: { slot: next || '', at: next ? next + 'T08:00:00.000Z' : null, day: next || '' } });
190
+ }
191
+ function post(path, b) {
192
+ const k = path.replace('/api/report-setups/', '');
193
+ if (k === 'ask') { if (!String(b.text || '').trim()) return json({ ok: false, error: 'say which report' }, 400); const a = { id: 'ask' + (++seq), text: String(b.text).trim(), state: 'running', t0: performance.now(), proposal: null, fail: /\bfail\b/i.test(b.text) }; a.proposal = proposalFromText(a.text); asks.unshift(a); return json({ ok: true, id: a.id }); }
194
+ if (k === 'change') {
195
+ const a = asks.find((x) => x.id === b.id); if (a) { a.state = 'running'; a.t0 = performance.now(); a.pending = String(b.text || ''); return json({ ok: true, id: a.id }); }
196
+ const f = findings.find((x) => x.id === b.id); if (f) { f.proposal = applyChange(f.proposal, b.text); return json({ ok: true, id: f.id }); }
197
+ return json({ ok: false, error: 'no such ask or finding' }, 404);
198
+ }
199
+ if (k === 'cancel') { asks = asks.filter((a) => a.id !== b.id); return json({ ok: true }); }
200
+ if (k === 'setup') return setupFrom(b);
201
+ if (k === 'dismiss') { findings = findings.filter((f) => f.id !== b.finding); dismissed.add(b.finding); return json({ ok: true }); }
202
+ if (k === 'pause' || k === 'resume') { const s = SETUPS.find((x) => x.id === b.id); if (!s) return json({ ok: false, error: 'no such set-up' }, 404); s.status = k === 'pause' ? 'paused' : 'active'; return json({ ok: true, setup: setupView(s) }); }
203
+ if (k === 'from-dashboard') {
204
+ if (!b.key) return json({ ok: false, error: 'a dashboard key is required' }, 400);
205
+ const id = 'dash' + (++seq);
206
+ const s = addSetup({ id, name: String(b.name || 'Dashboard snapshot'), purpose: 'A snapshot of the dashboard, as it is on the day.', audience: b.audience || 'Leadership', source: 'dashboard:' + b.key, output: b.output || 'pdf', schedule: b.schedule, next_draft: b.schedule && (b.schedule.day || b.schedule.dayOfMonth) ? iso(nextDraftDate(b.schedule) || new Date()) : null });
207
+ startDocs(s, null); DOCS.filter((d) => d.setup === id).forEach((d) => { d.rid = ({ portfolio: 'rgp' })[b.key] || 'rgi'; d.name = s.name; reviseDoc(d.path, 'claude-code/session', 'Snapshot of the dashboard', front(s.name, 'Mon 12 Oct 2026', s.output) + textOf(d.rid, { end: 'Mon 12 Oct 2026', name: s.name })); });
208
+ return json({ ok: true, setup: setupView(s) });
209
+ }
210
+ if (k === 'finder') { if (b.cadence) finder.cadence = b.cadence === 'off' ? 'off' : 'weekly'; if (b.sources) finder.sources = { ...finder.sources, ...b.sources }; return json({ ok: true, finder }); }
211
+ return json({ ok: false, error: 'no such route' }, 404);
212
+ }
213
+ /* a CSV of the tables in a text, else one column of lines */
214
+ function csvOf(text) {
215
+ const rows = []; for (const l of String(text).split('\n')) { if (/^\|[\s:|-]+\|?$/.test(l) || !l.startsWith('|')) continue; rows.push(l.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => '"' + c.trim().replace(/"/g, '""') + '"').join(',')); }
216
+ return rows.length ? rows.join('\n') : String(text).split('\n').filter(Boolean).map((l) => '"' + l.replace(/"/g, '""') + '"').join('\n');
217
+ }
218
+ const MIME = { pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', csv: 'text/csv' };
219
+ function exportFile(q) {
220
+ const d = DOCS.find((x) => x.path === q.get('path')); const f = q.get('fmt');
221
+ if (!d || !MIME[f]) return json({ ok: false, error: 'no such document or format' }, 400);
222
+ const body = f === 'csv' ? csvOf(docText(d.path)) : 'Demo file. The workspace builds the real ' + f + ' file from the text of ' + d.path + '.\n';
223
+ return new Response(body, { status: 200, headers: { 'content-type': MIME[f], 'content-disposition': 'attachment; filename="' + d.id + '.' + f + '"' } });
224
+ }
225
+ const SIGNAL = { 'in-step': 'same', 'pmos-ahead': 'ahead', 'copy-changed': 'behind', 'not-checked': 'unchecked' };
226
+ const WORDS = { same: 'Both have the same text.', ahead: 'PMOS has changes the copy does not have.', behind: 'The copy has a change PMOS does not have: Dana edited it.', unchecked: 'No check has read this copy yet.' };
227
+ function linkOf(d) {
228
+ if (!d.copy || d.copy.state === 'none' || !d.copy.kind) return null;
229
+ const sig = SIGNAL[d.copy.state] || 'unchecked';
230
+ return { kind: d.copy.kind, label: (FORMATS[d.output] || {}).app || 'Copy', target: (TARGET[d.copy.kind] || '') + d.id, url: null, title: d.name, signal: sig, basis: 'text', words: WORDS[sig], checked_at: '2026-10-12T21:00:00.000Z', checked_by: 'agent', remote_modified_at: '2026-10-12T20:00:00.000Z', remote_modified_by: 'Wawan', linked_at: '2026-10-12T20:00:00.000Z', syncing: null, sync_failed: null };
231
+ }
232
+ async function docsList(prev, input, init) {
233
+ const r = await prev(input, init); const j = await r.clone().json().catch(() => null);
234
+ if (!j || !j.ok) return r;
235
+ const mine = DOCS.map((d) => { const l = linkOf(d); return l ? { path: d.path, title: d.name, links: [l], attention: l.signal !== 'same' } : null; }).filter(Boolean);
236
+ return json({ ...j, docs: [...(j.docs || []), ...mine] });
237
+ }
238
+ const bodyOf = (init) => { try { return init && init.body ? JSON.parse(init.body) : {}; } catch { return {}; } };
239
+
240
+ export function installRs() {
241
+ SETUPS.length = 0; DOCS.length = 0; seedAll();
242
+ const prev = window.fetch;
243
+ window.fetch = (input, init) => {
244
+ try {
245
+ const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
246
+ const method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
247
+ const u = new URL(raw, location.origin); const p = u.pathname;
248
+ if (u.origin === location.origin) {
249
+ if (method === 'GET' && p === '/api/report-setups') return Promise.resolve(json({ ok: true, setups: SETUPS.map(setupView), findings: findings.map(({ proposal, next, ...f }) => ({ ...f, name: proposal.name, proposal: clone(proposal) })), asks: asks.map(askView), finder }));
250
+ if (method === 'GET' && p === '/api/report-documents') return Promise.resolve(json({ ok: true, docs: DOCS.map(docView) }));
251
+ if (method === 'GET' && p === '/api/report-export') return Promise.resolve(exportFile(u.searchParams));
252
+ if (method === 'GET' && p === '/api/docs') return docsList(prev, input, init);
253
+ if (method === 'POST' && p.startsWith('/api/report-setups/')) return Promise.resolve(post(p, bodyOf(init)));
254
+ /* Issue goes through the existing report routes: read the cycle (its sha), then POST the PM's issue with that sha */
255
+ const cyc = (c, s) => DOCS.find((x) => x.path.endsWith('/' + c + '/' + s + '.md'));
256
+ if (method === 'GET' && p === '/api/report/draft') {
257
+ const d = cyc(u.searchParams.get('cadence'), u.searchParams.get('slot'));
258
+ if (!d) return Promise.resolve(json({ ok: true, exists: false }));
259
+ return Promise.resolve(json({ ok: true, exists: true, path: d.path, sha: 'd'.repeat(40), markdown: null, status: d.state }));
260
+ }
261
+ if (method === 'POST' && p === '/api/report/issue') {
262
+ const b = bodyOf(init); const d = cyc(b.cadence, b.slot); if (!d) return Promise.resolve(json({ ok: false, error: 'no such report' }, 404));
263
+ d.state = 'issued'; reviseDoc(d.path, 'human:wawan', 'Issued', docText(d.path).replace('Draft, not issued.', 'Issued.')); d.v += 1; return Promise.resolve(json({ ok: true, doc: docView(d) }));
264
+ }
265
+ if (method === 'POST' && p === '/api/docs/create-copy') {
266
+ const b = bodyOf(init); const d = DOCS.find((x) => x.path === b.path); if (!d) return prev(input, init);
267
+ if (!d.copy.kind) return Promise.resolve(json({ ok: false, error: 'a PDF has no live copy' }, 400));
268
+ d.copy = { kind: d.copy.kind, state: 'in-step', url: null }; return Promise.resolve(json({ ok: true, session: 'demo-copy' }));
269
+ }
270
+ if (method === 'POST' && p === '/api/docs/sync') { const b = bodyOf(init); const d = DOCS.find((x) => x.path === b.path); if (d) { d.copy = { ...d.copy, state: 'in-step' }; return Promise.resolve(json({ ok: true, session: 'demo-sync' })); } }
271
+ if (method === 'POST' && p === '/api/docs/check') { const d = DOCS.find((x) => x.id === 'rs2-p1' && x.copy.state === 'in-step'); if (d) d.copy = { ...d.copy, state: 'copy-changed' }; }
272
+ /* an edit or an accepted proposal puts PMOS ahead of a copy that was in step */
273
+ if (method === 'POST' && (p === '/api/doc-sheet/edit' || p === '/api/doc-sheet/accept')) {
274
+ const b = bodyOf(init); const d = DOCS.find((x) => x.path === b.path);
275
+ if (d) return prev(input, init).then((r) => { r.clone().json().then((j) => { if (j && j.ok) { d.v += 1; if (d.copy.state === 'in-step') d.copy = { ...d.copy, state: 'pmos-ahead' }; } }).catch(() => {}); return r; });
276
+ }
277
+ }
278
+ } catch { /* an unreadable request goes on as it is */ }
279
+ return prev(input, init);
280
+ };
281
+ }
@@ -123,4 +123,4 @@ export function demoPill() {
123
123
  }
124
124
 
125
125
  export const DEMO_WS = D.WS_ID;
126
- export function installDemo() { freezeClock(); install(); installRep3(); installInit(); installBoards(); import('./c3-demo-rep2.js').then((m) => m.installRep2()); window.__c3demo = true; window.__c3demoReady = import('./c3-demo-brief-ix.js').then((m) => m.installBriefIx()).then(() => import('./c3-demo-misc2.js')).then((m) => m.installMisc2()).then(() => import('./c3-demo-brief-page.js')).then((m) => m.installBriefPage()); } // Briefing case files, people and a simulated agent (c3-shell.js waits for __c3demoReady)
126
+ export function installDemo() { freezeClock(); install(); installRep3(); installInit(); installBoards(); const repReady = import('./c3-demo-rep2.js').then((m) => { m.installRep2(); return import('./c3-demo-rs.js'); }).then((m) => m.installRs()); window.__c3demo = true; window.__c3demoReady = Promise.all([repReady, import('./c3-demo-brief-ix.js')]).then(([, m]) => m.installBriefIx()).then(() => import('./c3-demo-misc2.js')).then((m) => m.installMisc2()).then(() => import('./c3-demo-brief-page.js')).then((m) => m.installBriefPage()); } // Briefing case files, people and a simulated agent (c3-shell.js waits for __c3demoReady)
@@ -0,0 +1,152 @@
1
+ /* pm/c3-doc-formats.js — a report document drawn in its OUTPUT FORMAT (initiative report-workspace, PRD section 5; mockup 41-reportsetup.js formatView).
2
+ * The document is one markdown file. Read mode of the document sheet (c3-doc-sheet.js) calls formatView() to show the same text as
3
+ * google-slides 16:9 cards, one per section (the title card first; a section of **numbers** draws them large)
4
+ * google-sheets a grid: columns A, B, C, rows 1, 2, 3; a markdown table is the grid, any other section is one cell per line
5
+ * google-doc a page; confluence: the same page
6
+ * pdf A4 pages, sections filled in order
7
+ * It only draws. The text is never changed here; Edit stays markdown. Each section keeps the document sheet's own wrapper (who wrote it,
8
+ * pinned proposals), so the caller passes `wrap(section, inner, label)`.
9
+ * Pure parts (Node-testable): FORMATS, formatOf, tableRows, bigRows, kindOf, partsOf, pagesOf, outlineLabels, printHtml, downloadsOf, copyKindFor. */
10
+ import { el } from './dom.js';
11
+
12
+ /** Link c3-report-setup.css once, from this module's own folder (the way c3-scan.js links its stylesheet). */
13
+ export function ensureReportCss() {
14
+ if (typeof document === 'undefined' || document.querySelector('link[data-c3-report-setup]')) return;
15
+ const l = document.createElement('link');
16
+ l.rel = 'stylesheet'; l.href = new URL('./c3-report-setup.css', import.meta.url).href; l.setAttribute('data-c3-report-setup', '');
17
+ document.head.appendChild(l);
18
+ }
19
+
20
+ /** The five outputs of a recurring report. l: the name the page shows; ic: its icon; app: the tool a copy lives in; dl: the Download menu, [label, fmt]. */
21
+ export const FORMATS = {
22
+ 'google-slides': { l: 'Google Slides', ic: 'img', app: 'Google Slides', copy: 'google-slides', word: 'slides', dl: [['PDF', 'pdf'], ['PowerPoint .pptx', 'pptx']] },
23
+ 'google-doc': { l: 'Google Doc', ic: 'doc', app: 'Google Doc', copy: 'google', word: 'headings', dl: [['PDF', 'pdf'], ['Word .docx', 'docx']] },
24
+ 'google-sheets': { l: 'Google Sheets', ic: 'sheet', app: 'Google Sheets', copy: 'google-sheets', word: 'sheets', dl: [['Excel .xlsx', 'xlsx'], ['CSV', 'csv']] },
25
+ confluence: { l: 'Confluence', ic: 'notes', app: 'Confluence', copy: 'confluence', word: 'headings', dl: [['PDF', 'pdf'], ['Word .docx', 'docx']] },
26
+ pdf: { l: 'PDF', ic: 'pdf', app: '', copy: '', word: 'sections', dl: [['PDF', 'pdf']] },
27
+ };
28
+ export const OUTPUTS = ['google-slides', 'google-doc', 'google-sheets', 'confluence', 'pdf'];
29
+ export const formatOf = (o) => FORMATS[o] || FORMATS['google-doc'];
30
+ /** The copy kind (lib/doc-sync.js) an output keeps its live copy in; '' for a PDF. */
31
+ export const copyKindFor = (output) => formatOf(output).copy;
32
+ /** The Download items of an output: [{ label, fmt }]. */
33
+ export const downloadsOf = (output) => formatOf(output).dl.map(([label, fmt]) => ({ label, fmt }));
34
+
35
+ const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
36
+ const lines = (t) => String(t || '').split('\n').map((l) => l.trim()).filter(Boolean);
37
+ const plainInline = (s) => String(s).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/^[-*]\s+/, '');
38
+
39
+ /** The rows of a markdown table in `text`: [[cell, ...], ...] without the separator row; null when the text has no table. */
40
+ export function tableRows(text) {
41
+ const L = lines(text).filter((l) => l.startsWith('|'));
42
+ if (L.length < 2) return null;
43
+ const rows = L.filter((l) => !/^\|[\s:|-]+\|?$/.test(l)).map((l) => l.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => plainInline(c.trim())));
44
+ return rows.length ? rows : null;
45
+ }
46
+ /** Numbers set large on a slide: every line is a bullet that starts with a bold number, "- **97.4%** Login success". [[number, what]] or null. */
47
+ export function bigRows(text) {
48
+ const L = lines(text); if (!L.length) return null;
49
+ const out = [];
50
+ for (const l of L) { const m = /^[-*]\s+\*\*([-+]?[\d][\d.,]*\s?(?:%|pts)?)\*\*\s*(.*)$/.exec(l); if (!m) return null; out.push([m[1], plainInline(m[2])]); }
51
+ return out;
52
+ }
53
+ /** What a section is: 'title' (the H1), 'grid' (a table), 'big' (numbers) or 'text'. */
54
+ export function kindOf(s) {
55
+ if (/^#\s/.test(s.prefix || '')) return 'title';
56
+ if (tableRows(s.text)) return 'grid';
57
+ if (bigRows(s.text)) return 'big';
58
+ return 'text';
59
+ }
60
+ /** The sections a format draws: every named section but the front matter, with its kind. The H1 is the title card; a document with no H1 gets none. */
61
+ export function partsOf(secs) {
62
+ return secs.filter((s) => s.prefix).map((s) => ({ s, kind: kindOf(s) }));
63
+ }
64
+ /** The outline of a report: the title card counts as a slide or a sheet. [{ s, lv: 1, label }]. */
65
+ export function outlineLabels(secs, output) {
66
+ const w = formatOf(output).word; const P = partsOf(secs);
67
+ return P.map((p, i) => ({ s: p.s, lv: 1, label: (w === 'slides' ? (i + 1) + '. ' : w === 'sheets' ? 'Sheet ' + (i + 1) + ': ' : '') + (p.kind === 'title' ? 'Title' : p.s.name) }));
68
+ }
69
+ /** A4 pages: the sections in order, a page holding about 26 lines. [[part, ...], ...]. */
70
+ export function pagesOf(parts, limit = 26) {
71
+ const pages = []; let cur = []; let n = 0;
72
+ for (const p of parts) {
73
+ const w = 3 + lines(p.s.text).length;
74
+ if (cur.length && n + w > limit) { pages.push(cur); cur = []; n = 0; }
75
+ cur.push(p); n += w;
76
+ }
77
+ if (cur.length) pages.push(cur);
78
+ return pages.length ? pages : [[]];
79
+ }
80
+
81
+ /* ── the drawn views ── */
82
+ const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
83
+ function gridNode(s) {
84
+ let rows = tableRows(s.text);
85
+ if (!rows) rows = lines(s.text).map((l) => [plainInline(l)]);
86
+ const width = Math.max(1, ...rows.map((r) => r.length));
87
+ const cls = (c, i) => (/^[-+]?[\d.,]+(%| pts)?$|^[-+]\d/.test(c) ? 'n' : '') + (/^\+\d/.test(c) && i ? ' pos' : /^-\d|^−\d/.test(c) && i ? ' neg' : '');
88
+ return el('div', { class: 'rs-gridwrap' }, el('table', { class: 'rs-grid' },
89
+ el('thead', null, el('tr', null, el('th'), ...Array.from({ length: width }, (_, i) => el('th', null, letters[i] || '')))),
90
+ el('tbody', null, rows.map((r, i) => el('tr', { class: i === 0 && tableRows(s.text) ? 'hd' : '' }, el('th', null, String(i + 1)), ...Array.from({ length: width }, (_, j) => el('td', { class: cls(r[j] || '', i) }, r[j] || '')))))));
91
+ }
92
+ const bigNode = (rows) => el('div', { class: 'rs-big' }, rows.map(([n, t]) => el('div', null, el('b', null, n), el('span', null, t))));
93
+
94
+ /**
95
+ * The format view. o = { output, name, cycle, secs (splitDoc of the text), md(text) -> Node, wrap(section, inner, label) -> Node, foot }.
96
+ * Returns the Node to put where Read draws the sections.
97
+ */
98
+ export function formatView(o) {
99
+ ensureReportCss();
100
+ const f = o.output; const P = partsOf(o.secs); const n = P.length;
101
+ const body = (p, mode) => {
102
+ if (p.kind === 'grid') return mode === 'sheets' ? gridNode(p.s) : o.md(p.s.text);
103
+ if (p.kind === 'big' && mode === 'slides') return bigNode(bigRows(p.s.text));
104
+ if (mode === 'sheets' && p.kind !== 'title') return gridNode(p.s);
105
+ return p.s.text.trim() ? o.md(p.s.text) : el('p', { class: 'dim' }, p.kind === 'title' ? '' : 'This section is empty.');
106
+ };
107
+ if (f === 'google-slides') {
108
+ return el('div', { class: 'rs-slides', 'data-rs-format': 'slides' }, P.map((p, i) => o.wrap(p.s, el('div', { class: 'rs-slide' + (p.kind === 'title' ? ' title' : ''), role: 'group', 'aria-label': 'Slide ' + (i + 1), 'data-rs-slide': String(i + 1) },
109
+ el('div', { class: 'rs-sh' }, p.kind === 'title' ? o.name : p.s.name), el('div', { class: 'rs-sb' }, body(p, 'slides')),
110
+ el('div', { class: 'rs-sf' }, el('span', null, o.foot || ''), el('span', null, (i + 1) + ' of ' + n))), 'Slide ' + (i + 1))));
111
+ }
112
+ if (f === 'google-sheets') {
113
+ return el('div', { class: 'rs-sheets', 'data-rs-format': 'sheets' }, P.map((p, i) => o.wrap(p.s, body(p, 'sheets'), 'Sheet ' + (i + 1) + ': ' + (p.kind === 'title' ? 'Title' : p.s.name))));
114
+ }
115
+ if (f === 'pdf') {
116
+ const pages = pagesOf(P);
117
+ return el('div', { class: 'rs-pages', 'data-rs-format': 'pdf' }, pages.map((pg, k) => el('article', { class: 'rs-page a4', 'aria-label': 'Page ' + (k + 1), 'data-rs-page': String(k + 1) },
118
+ k === 0 ? [el('h3', null, o.name), o.cycle ? el('p', { class: 'm' }, o.cycle) : null] : null,
119
+ pg.filter((p) => p.kind !== 'title').map((p) => o.wrap(p.s, body(p, 'pdf'))), el('div', { class: 'rs-pn' }, 'Page ' + (k + 1) + ' of ' + pages.length))));
120
+ }
121
+ return el('article', { class: 'rs-page doc', 'data-rs-format': f === 'confluence' ? 'confluence' : 'doc' }, el('h3', null, o.name + (o.cycle ? ' · ' + o.cycle : '')),
122
+ P.filter((p) => p.kind !== 'title').map((p) => o.wrap(p.s, body(p, 'doc'))));
123
+ }
124
+
125
+ /* ── the printable view: Download > PDF opens it in a window and the browser prints it ── */
126
+ const inlineHtml = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/`([^`]+)`/g, '<code>$1</code>');
127
+ function blockHtml(text) {
128
+ const rows = tableRows(text);
129
+ if (rows) return '<table>' + rows.map((r, i) => '<tr>' + r.map((c) => (i === 0 ? '<th>' : '<td>') + esc(c) + (i === 0 ? '</th>' : '</td>')).join('') + '</tr>').join('') + '</table>';
130
+ const out = []; let ul = [];
131
+ const flush = () => { if (ul.length) { out.push('<ul>' + ul.join('') + '</ul>'); ul = []; } };
132
+ for (const l of lines(text)) { if (/^[-*]\s+/.test(l)) ul.push('<li>' + inlineHtml(l.replace(/^[-*]\s+/, '')) + '</li>'); else { flush(); out.push('<p>' + inlineHtml(l.replace(/^#+\s+/, '')) + '</p>'); } }
133
+ flush(); return out.join('');
134
+ }
135
+ /* the print styles live in c3-report-print.css, on the tokens; the window links both, absolute, since it has no base URL */
136
+ const sheet = (f) => '<link rel="stylesheet" href="' + esc(new URL('./' + f, import.meta.url).href) + '">';
137
+ /** The whole printable page as a string: slides are landscape 16:9 cards, everything else is A4. o = { output, name, cycle, secs }. */
138
+ export function printHtml(o) {
139
+ const P = partsOf(o.secs); const slides = o.output === 'google-slides';
140
+ let body;
141
+ if (slides) body = P.map((p) => '<section class="rp-slide' + (p.kind === 'title' ? ' rp-slide--title' : '') + '"><h2>' + esc(p.kind === 'title' ? o.name : p.s.name) + '</h2>' + blockHtml(p.s.text) + '</section>').join('');
142
+ else body = '<h1>' + esc(o.name) + '</h1>' + (o.cycle ? '<p class="rp-print-m">' + esc(o.cycle) + '</p>' : '') + P.filter((p) => p.kind !== 'title').map((p) => '<h2>' + esc(p.s.name) + '</h2>' + blockHtml(p.s.text)).join('');
143
+ return '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>' + esc(o.name + (o.cycle ? ' ' + o.cycle : '')) + '</title>' + sheet('tokens.css') + sheet('c3-report-print.css') + '</head><body class="rp-print' + (slides ? ' rp-print-slides' : '') + '">' + body + '</body></html>';
144
+ }
145
+ /** Open the printable view in a window and print it. Returns false when the browser blocked the window. */
146
+ export function printPdf(o) {
147
+ const w = typeof window !== 'undefined' && window.open ? window.open('', '_blank') : null;
148
+ if (!w) return false;
149
+ w.document.open(); w.document.write(printHtml(o)); w.document.close();
150
+ w.focus(); setTimeout(() => { try { w.print(); } catch { /* the PM can print from the window */ } }, 250);
151
+ return true;
152
+ }