create-pmos 0.493.0 → 0.495.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.
- package/package.json +99 -27
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/briefing-premium-adddoc-test.js +2 -1
- package/workspace/briefing-premium-board-test.js +5 -4
- package/workspace/briefing-premium-initiative-test.js +4 -2
- package/workspace/changelog.json +98 -0
- package/workspace/guide/briefing-premium.md +4 -1
- package/workspace/guide/claude-proposes.md +2 -2
- package/workspace/guide/data-freshness.md +3 -1
- package/workspace/guide/initiatives-screen.md +3 -1
- package/workspace/guide/report-slides.md +14 -1
- package/workspace/guide/reports.md +2 -0
- package/workspace/guide/workflows.md +15 -3
- package/workspace/guide/your-impact.md +6 -7
- package/workspace/ini-workflow-test.js +2 -1
- package/workspace/lib/board-trail.js +30 -3
- package/workspace/lib/engine/workflows.js +19 -6
- package/workspace/lib/git-shape.js +21 -0
- package/workspace/lib/grill-runs.js +21 -6
- package/workspace/lib/grill.js +1 -1
- package/workspace/lib/impact.js +29 -16
- package/workspace/lib/initiative-cli.js +15 -1
- package/workspace/lib/open-mission.js +14 -0
- package/workspace/lib/page-warm.js +68 -0
- package/workspace/lib/report-design.js +6 -1
- package/workspace/lib/report-export.js +150 -9
- package/workspace/lib/report-pyramid.js +535 -0
- package/workspace/lib/report-structure.js +5 -1
- package/workspace/lib/report-templates.js +111 -20
- package/workspace/lib/routes-boards.js +36 -3
- package/workspace/lib/routes-brief-page.js +21 -3
- package/workspace/lib/routes-grill.js +10 -4
- package/workspace/lib/routes-impact.js +12 -4
- package/workspace/lib/routes-initiatives.js +1 -0
- package/workspace/lib/routes-programs.js +6 -4
- package/workspace/lib/routes-report-design.js +15 -0
- package/workspace/lib/routes-report-flow.js +115 -19
- package/workspace/lib/routes-report-setups.js +4 -2
- package/workspace/lib/routes-report-structure.js +3 -0
- package/workspace/lib/routes-workflows.js +6 -1
- package/workspace/lib/wf-bridge.js +5 -3
- package/workspace/lib/workflow-cli.js +2 -2
- package/workspace/lib/workflows-store.js +15 -1
- package/workspace/metric-drafts-ui-test.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-board-actions-test.js +2 -1
- package/workspace/premium-chat-report-tab-test.js +5 -1
- package/workspace/premium-vw-backlog-test.js +3 -2
- package/workspace/premium-vw-item-test.js +3 -2
- package/workspace/premium-vw-jira-test.js +3 -2
- package/workspace/program-panel-test.js +2 -1
- package/workspace/public/pm/cozy-home.css +1 -0
- package/workspace/public/pm/cozy-vw-rp.css +12 -0
- package/workspace/public/pm/fetch-share.js +38 -0
- package/workspace/public/pm/home-app.js +24 -4
- package/workspace/public/pm/home-chat.js +53 -11
- package/workspace/public/pm/home-impact.css +23 -13
- package/workspace/public/pm/home-impact.js +49 -32
- package/workspace/public/pm/home-ini.css +2 -0
- package/workspace/public/pm/ini-view.css +55 -0
- package/workspace/public/pm/ini-view.js +7 -4
- package/workspace/public/pm/premium-board.js +1 -0
- package/workspace/public/pm/premium-insights.js +1 -0
- package/workspace/public/pm/premium-report-read.js +25 -6
- package/workspace/public/pm/premium-report-tab.js +69 -14
- package/workspace/public/pm/premium-shell.js +57 -6
- package/workspace/public/pm/premium.css +1 -0
- package/workspace/public/pm/report-flow.js +93 -10
- package/workspace/public/pm/wf-page.css +8 -0
- package/workspace/public/pm/wf-page.js +33 -5
- package/workspace/server.js +24 -3
- package/workspace/skills/report-template.skill +10 -0
- package/workspace/split-merge-ui-test.js +3 -2
- package/workspace/stage-card-test.js +2 -1
- package/workspace/task-press-test.js +2 -1
- package/workspace/test-lists/page-open-speed.json +5 -0
- package/workspace/test-lists/report-pyramid.json +9 -0
- package/workspace/tk-tasks-test.js +2 -1
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-pyramid.js — the finished slides of a cadence report: the Pyramid deck (canvas RsA + RsD, agreed by Wawan 2026-11-02, option A).
|
|
3
|
+
*
|
|
4
|
+
* readDraft(markdown, extra?) → the report's parts, read back from the draft's markdown (pure) | null when it is not a cadence draft
|
|
5
|
+
* deckOf(markdown, look?, extra?) → { slides: [{ kind, title, …, source, n }], look } | null (pure; extra.data = the cycle's <slot>.data.json)
|
|
6
|
+
* dataOf(absDraftPath) → the structured cycle's <slot>.data.json next to the draft, or null
|
|
7
|
+
* deckHtml(deck) → one HTML string: a <style> block and one <section class="pmos-slide"> per slide, 1280 x 720 each (pure)
|
|
8
|
+
* lookOf(root, cadence) → the look the slides use: the report's saved design D<n> (lib/report-design.js), else the standard PMOS look
|
|
9
|
+
* STANDARD → the standard PMOS look
|
|
10
|
+
*
|
|
11
|
+
* THE SLIDES, in this order. Every title is a finding: reading only the titles tells the story.
|
|
12
|
+
* title the answer is the title: the first sentence of the PM's headline note (capped, so it fits), else a sentence about who is off plan
|
|
13
|
+
* answer the governing thought, the rest of the headline note's first paragraph, up to three supports (one per initiative, off plan
|
|
14
|
+
* first) and the ask beside them
|
|
15
|
+
* scorecard the WBR scorecard: stage, status (shape and colour), done of total (+Δ), the KR now vs target with the gap. A comment
|
|
16
|
+
* only on a row that is off plan (off track or at risk); a row on plan is silent. Seven rows per slide.
|
|
17
|
+
* timeline the dated work of the Timeline section, a due-date diamond per initiative and what is overdue, with an action title
|
|
18
|
+
* that compares two due dates. Not drawn when nothing has a date (zero is silence).
|
|
19
|
+
* ask Needs your call: only when the definition names an audience AND the asks note has words. Who, by when (only when the
|
|
20
|
+
* note says it) and the options (the note's bullet lines). Nothing is invented.
|
|
21
|
+
* sources Sources reached, and "What we don't know yet": each source not reached, with its reason. Read from the script's section, the
|
|
22
|
+
* cycle's data file and the agent's "Sources:" paragraph, so the title never says "every source was reached" when one was not.
|
|
23
|
+
* Not drawn, because the draft does not read them (RsD): KR sparklines, last-quarter bars, forecast ranges.
|
|
24
|
+
*
|
|
25
|
+
* WHERE IT IS USED. One model, three outputs: the in-app slide view (GET /api/report-design/pyramid → deckHtml; premium-report-read.js shows
|
|
26
|
+
* it when the cycle has no designed render), the PDF (the browser prints the same frames, one per page) and the PowerPoint file
|
|
27
|
+
* (lib/report-export.js pptx draws the same slides with shapes). A cycle drawn to its saved design keeps that render: it is the look.
|
|
28
|
+
*
|
|
29
|
+
* THE LOOK. lookOf reads the report's saved design version through report-design.js only (the template's T lines version the same D<n>,
|
|
30
|
+
* initiative report template A). From its style it takes the first font family and, as the accent, the design's own title or primary colour
|
|
31
|
+
* (a --primary/--brand variable, else its title slide's or h1's background, then text colour); only when it names none, the most used dark
|
|
32
|
+
* colour. White text on the accent passes 4.5:1. No saved design: the standard PMOS look (Nunito, navy).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const STANDARD = Object.freeze({
|
|
36
|
+
name: 'Standard PMOS look', version: null, font: 'Nunito', accent: '#24304a', ink: '#2b2a27', muted: '#6b655a', soft: '#4a4740',
|
|
37
|
+
line: '#efeae0', track: '#ece7dc', ground: '#ffffff', ask: '#f7f9fd', on: '#3f8f5f', risk: '#b8730f', off: '#b8403a', today: '#3b6fd4', unknown: '#8a8478',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const MONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
41
|
+
const NUM = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
|
|
42
|
+
const LEVELS = { 'Off track': 'off', 'At risk': 'at-risk', Unknown: 'unknown', 'On track': 'on' };
|
|
43
|
+
const WORD = { off: 'off track', 'at-risk': 'at risk', unknown: 'unknown', on: 'on track' };
|
|
44
|
+
const NOTE_RE = /<!-- pmos:note ([a-z0-9][a-z0-9._-]*) -->\n?([\s\S]*?)\n?<!-- \/pmos:note -->/g;
|
|
45
|
+
const SLIDES_RE = /<!-- pmos:slides v1 (\{[\s\S]*?\}) -->/;
|
|
46
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
47
|
+
const ROWS_PER_SLIDE = 7;
|
|
48
|
+
const DAY = 86400000;
|
|
49
|
+
|
|
50
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
51
|
+
const isObjP = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
52
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
53
|
+
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
|
54
|
+
const num = (n) => (n >= 0 && n < NUM.length ? NUM[n] : String(n));
|
|
55
|
+
const plain = (s) => str(s).replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').trim();
|
|
56
|
+
const ms = (ymd) => Date.parse(ymd + 'T00:00:00Z');
|
|
57
|
+
/** "16 Oct" from YYYY-MM-DD (or the text as it is). */
|
|
58
|
+
const short = (ymd) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(str(ymd)); return m ? `${+m[3]} ${MONS[+m[2] - 1]}` : str(ymd); };
|
|
59
|
+
/** "10 Oct" → YYYY-MM-DD in the year that puts it on or before `today` (the draft writes short dates for carried items). */
|
|
60
|
+
function fromShort(s, today) {
|
|
61
|
+
const m = /^(\d{1,2}) ([A-Z][a-z]{2})$/.exec(str(s).trim());
|
|
62
|
+
const k = m ? MONS.indexOf(m[2]) : -1;
|
|
63
|
+
if (k < 0 || !DATE_RE.test(str(today))) return null;
|
|
64
|
+
let y = +today.slice(0, 4);
|
|
65
|
+
const at = (yy) => `${yy}-${String(k + 1).padStart(2, '0')}-${String(+m[1]).padStart(2, '0')}`;
|
|
66
|
+
if (at(y) > today) y -= 1;
|
|
67
|
+
return at(y);
|
|
68
|
+
}
|
|
69
|
+
const join = (xs) => (xs.length < 2 ? xs.join('') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1]);
|
|
70
|
+
const firstLine = (s) => plain(str(s).split('\n').map((l) => l.trim()).find((l) => l && !/^<!--/.test(l)) || '');
|
|
71
|
+
/** The sentences of one paragraph. A full stop ends a sentence only before a space and a capital, a digit or a quote. */
|
|
72
|
+
const sentences = (p) => str(p).split(/(?<=[.!?])\s+(?=["“'(A-Z0-9])/).map((x) => x.trim()).filter(Boolean);
|
|
73
|
+
/** Cut a text at a word boundary to at most n characters, with an ellipsis when cut. */
|
|
74
|
+
const clip = (t, n) => { const x = str(t).trim(); if (x.length <= n) return x; const c = x.slice(0, n - 1); const i = c.lastIndexOf(' '); return (i > n * 0.6 ? c.slice(0, i) : c).replace(/[\s,;:–—-]+$/, '') + '…'; };
|
|
75
|
+
const TITLE_MAX = 120; const TITLE_FIT = 90; const LEAD_MAX = 320; const ACTION_MAX = 110;
|
|
76
|
+
// the scorecard's rows fit a height: a row with a KR bullet is taller (brief 4 item 4: twelve rows ran into the key and the footer)
|
|
77
|
+
const ROWS_HEIGHT = 340; const rowHeight = (r) => (r.measure && r.measure.target != null ? 80 : 48);
|
|
78
|
+
/** "2 Nov 2026, 22:00 UTC" from an ISO time (or the text as it is). */
|
|
79
|
+
const human = (iso) => { const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(str(iso)); return m ? `${+m[3]} ${MONS[+m[2] - 1]} ${m[1]}, ${m[4]}:${m[5]} UTC` : str(iso); };
|
|
80
|
+
/** The window's reason in words: the drafter writes "since the last issue" or "report_cycle_days back" (its rule's name). */
|
|
81
|
+
function sinceWords(w) {
|
|
82
|
+
if (!w || !w.since) return '';
|
|
83
|
+
if (/report_cycle_days/.test(w.since)) { const d = Math.round((Date.parse(w.to) - Date.parse(w.fromAt || w.from)) / DAY); return Number.isFinite(d) && d > 0 ? `the last ${d} day${d === 1 ? '' : 's'}` : ''; }
|
|
84
|
+
return w.since.replace(/_/g, ' ');
|
|
85
|
+
}
|
|
86
|
+
/** The sources not reached that the agent named in a note's "Sources:" paragraph: "Not reached: Jira CLDS board (no connector), events list…". */
|
|
87
|
+
function notReachedInNotes(notes) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const v of Object.values(notes || {})) {
|
|
90
|
+
for (const para of str(v).split(/\n\s*\n/)) {
|
|
91
|
+
const t = plain(para.replace(/\s+/g, ' '));
|
|
92
|
+
if (!/^Sources\b/i.test(t)) continue;
|
|
93
|
+
const m = /\b(?:not reached|could not (?:be )?(?:read|reach)(?: these)?|unreachable)\b\s*(?:were|was)?\s*[:,-]?\s*([^.]*(?:\([^)]*\)[^.]*)*)/i.exec(t);
|
|
94
|
+
if (!m) continue;
|
|
95
|
+
for (const raw of m[1].split(/;\s*|,\s*(?![^()]*\))|\s+and\s+(?![^()]*\))/)) {
|
|
96
|
+
const x = raw.trim().replace(/^(?:and|the)\s+/i, '');
|
|
97
|
+
if (!x || /^none\b/i.test(x)) continue;
|
|
98
|
+
const p = /^(.*?)\s*\(([^)]*)\)\s*$/.exec(x);
|
|
99
|
+
out.push(p ? { source: p[1].trim(), why: p[2].trim() } : { source: x, why: '' });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out.filter((x) => x.source);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── reading the draft back ──────────────────────────────────────────────────────────────────────────
|
|
107
|
+
function frontmatter(md) {
|
|
108
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(md);
|
|
109
|
+
const out = {};
|
|
110
|
+
if (!m) return out;
|
|
111
|
+
for (const l of m[1].split(/\r?\n/)) { const k = /^([a-z_]+):\s*(.*)$/.exec(l); if (k) out[k[1]] = k[2].replace(/^"(.*)"$/, '$1'); }
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
function sectionsOf(md) {
|
|
115
|
+
const out = new Map();
|
|
116
|
+
let cur = null;
|
|
117
|
+
for (const l of md.replace(/\r\n?/g, '\n').split('\n')) {
|
|
118
|
+
const h = /^## (.+)$/.exec(l);
|
|
119
|
+
if (h) { cur = h[1].trim(); out.set(cur, []); continue; }
|
|
120
|
+
if (cur) out.get(cur).push(l);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
const bullets = (lines) => lines.filter((l) => /^- /.test(l)).map((l) => l.slice(2).trim());
|
|
125
|
+
function notesOf(md) { const o = {}; for (const m of md.matchAll(NOTE_RE)) if (!(m[1] in o)) o[m[1]] = m[2].trim(); return o; }
|
|
126
|
+
function progressOf(label) {
|
|
127
|
+
const m = /^(\d+) of (\d+)(?: \(\+(\d+)\))?/.exec(str(label));
|
|
128
|
+
return m ? { done: +m[1], total: +m[2], delta: m[3] == null ? null : +m[3], label } : { done: null, total: null, delta: null, label };
|
|
129
|
+
}
|
|
130
|
+
function measureOf(line) {
|
|
131
|
+
const m = /^Measured: (\S+) (-?\d+(?:\.\d+)?)([^\s,]*)(?: of (-?\d+(?:\.\d+)?)[^\s,]*)?, as of (\S+) \((.+)\)$/.exec(str(line).trim());
|
|
132
|
+
return m ? { anchor: m[1], value: +m[2], unit: m[3] || '', target: m[4] == null ? null : +m[4], as_of: m[5], source: m[6] } : null;
|
|
133
|
+
}
|
|
134
|
+
function blocksOf(lines, notes, today) {
|
|
135
|
+
const out = [];
|
|
136
|
+
let b = null; let part = null;
|
|
137
|
+
for (const raw of lines) {
|
|
138
|
+
const l = raw.trim();
|
|
139
|
+
const h = /^### (.+) \(([^()]+)\)$/.exec(l);
|
|
140
|
+
if (h) { b = { title: h[1], id: h[2], stage: '', level: 'unknown', reason: '', progress: progressOf(''), measure: null, carried: [], coming: [], note: notes[h[2]] || '' }; out.push(b); part = null; continue; }
|
|
141
|
+
if (!b) continue;
|
|
142
|
+
if (/^Stage: /.test(l)) {
|
|
143
|
+
const bits = l.slice(7).split(' · ');
|
|
144
|
+
b.stage = bits[0] || '';
|
|
145
|
+
b.progress = progressOf(bits.length > 2 ? bits[bits.length - 1] : '');
|
|
146
|
+
const lv = /^(Off track|At risk|Unknown|On track)(?:: (.*))?$/.exec(bits.slice(1, bits.length > 2 ? -1 : undefined).join(' · '));
|
|
147
|
+
if (lv) { b.level = LEVELS[lv[1]]; b.reason = lv[2] || ''; }
|
|
148
|
+
} else if (/^Measured: /.test(l)) b.measure = measureOf(l);
|
|
149
|
+
else if (l === '**Last cycle**') part = 'last';
|
|
150
|
+
else if (/^\*\*Coming cycle\*\*/.test(l)) part = 'coming';
|
|
151
|
+
else if (/^- Overdue, carried over: /.test(l) && part === 'last') {
|
|
152
|
+
const v = l.replace(/^- Overdue, carried over: /, '');
|
|
153
|
+
if (v !== 'none') for (const x of v.split('; ')) { const m = /^(.*), due (.+)$/.exec(x); if (m) b.carried.push({ title: m[1], due: fromShort(m[2], today), dueText: m[2] }); }
|
|
154
|
+
} else if (/^- /.test(l) && part === 'coming' && l !== '- none') b.coming.push(l.slice(2));
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
function timelineOf(lines) {
|
|
159
|
+
const rows = [];
|
|
160
|
+
let cur = null;
|
|
161
|
+
for (const l of lines) {
|
|
162
|
+
if (!/^\|/.test(l) || /^\|\s*-/.test(l) || /^\| Initiative \|/.test(l)) continue;
|
|
163
|
+
const c = l.slice(1, l.endsWith('|') ? -1 : undefined).split(/(?<!\\)\|/).map((x) => x.trim().replace(/\\\|/g, '|'));
|
|
164
|
+
if (c[0]) { cur = { title: c[0], spans: [], holds: [] }; rows.push(cur); }
|
|
165
|
+
if (!cur) continue;
|
|
166
|
+
if (/^On hold/.test(c[1])) { if (DATE_RE.test(c[2])) cur.holds.push({ from: c[2], to: DATE_RE.test(c[3]) ? c[3] : null }); continue; }
|
|
167
|
+
if (c[1] === 'No activity') continue;
|
|
168
|
+
const start = DATE_RE.test(c[2]) ? c[2] : null; const due = DATE_RE.test(c[3]) ? c[3] : null;
|
|
169
|
+
if (start || due) cur.spans.push({ stage: c[1] === '-' ? '' : c[1], start: start || due, due: due || start });
|
|
170
|
+
}
|
|
171
|
+
return rows.filter((r) => r.spans.length);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The parts of a cadence draft (lib/report-draft.js render), or null when the markdown is not one. */
|
|
175
|
+
function readDraft(markdown, extra) {
|
|
176
|
+
const md = str(markdown);
|
|
177
|
+
const cycleData = extra && isObjP(extra.data) ? extra.data : null;
|
|
178
|
+
const S = sectionsOf(md);
|
|
179
|
+
if (!S.has('Headline') || !S.has('Initiatives') || !S.has('Sources reached and not reached')) return null; // the drafter's frame (report-draft.js render)
|
|
180
|
+
const fm = frontmatter(md);
|
|
181
|
+
const notes = notesOf(md);
|
|
182
|
+
let data = null;
|
|
183
|
+
const sj = SLIDES_RE.exec(md);
|
|
184
|
+
if (sj) { try { data = JSON.parse(sj[1]); } catch { data = null; } }
|
|
185
|
+
const win = /^(\S+) to (\S+)(?: \((.+)\))?$/.exec(str(fm.window));
|
|
186
|
+
const slot = DATE_RE.test(str(fm.slot)) ? fm.slot : null;
|
|
187
|
+
const today = (data && data.meta && DATE_RE.test(str(data.meta.today)) && data.meta.today) || slot || (win ? win[2].slice(0, 10) : null);
|
|
188
|
+
const head = (S.get('Headline') || []).map((l) => l.trim()).find((l) => l && !/^<!--/.test(l)) || '';
|
|
189
|
+
const h1 = /^# (.+)$/m.exec(md);
|
|
190
|
+
const needs = S.get('Needs your call') || [];
|
|
191
|
+
const aud = needs.map((l) => /^For (.+) only\.$/.exec(l.trim())).find(Boolean);
|
|
192
|
+
const src = S.get('Sources reached and not reached') || [];
|
|
193
|
+
const at = src.findIndex((l) => l.trim() === '**Not reached**');
|
|
194
|
+
const reached = bullets(at < 0 ? src : src.slice(0, at)).filter((x) => x !== 'none');
|
|
195
|
+
const notReached = (at < 0 ? [] : bullets(src.slice(at))).filter((x) => x !== 'none').map((x) => { const i = x.indexOf(': '); return i < 0 ? { source: x, why: '' } : { source: x.slice(0, i), why: x.slice(i + 2) }; });
|
|
196
|
+
// a structured report's sources live in its <slot>.data.json and in the agent's "Sources:" paragraph, not in the script's section (brief 4 item 6)
|
|
197
|
+
const seen = new Set(notReached.map((x) => x.source.toLowerCase()));
|
|
198
|
+
const more = (cycleData && Array.isArray(cycleData.sources) ? cycleData.sources : []).filter((x) => x && x.reached === false && str(x.id || x.name)).map((x) => ({ source: str(x.name || x.id), why: str(x.reason) }))
|
|
199
|
+
.concat(notReachedInNotes(notes));
|
|
200
|
+
for (const x of more) { const k = x.source.toLowerCase(); if (!seen.has(k)) { seen.add(k); notReached.push(x); } }
|
|
201
|
+
for (const x of cycleData && Array.isArray(cycleData.sources) ? cycleData.sources : []) {
|
|
202
|
+
const k = str(x && (x.name || x.id));
|
|
203
|
+
if (x && x.reached === true && k && !reached.some((r) => r.toLowerCase() === k.toLowerCase())) reached.push(k);
|
|
204
|
+
}
|
|
205
|
+
const product = data && data.meta ? str(data.meta.product) : '';
|
|
206
|
+
return {
|
|
207
|
+
title: (h1 ? h1[1] : str(fm.title)).trim(), name: (h1 ? h1[1] : str(fm.title)).replace(/\s+\d{4}-\d{2}-\d{2}$/, '').trim(),
|
|
208
|
+
cadence: str(fm.cadence), slot, product, today,
|
|
209
|
+
window: win ? { from: win[1].slice(0, 10), fromAt: win[1], to: win[2], since: win[3] || '' } : null,
|
|
210
|
+
headline: head, headlineNote: firstLine(notes['report.headline']),
|
|
211
|
+
blocks: blocksOf(S.get('Initiatives'), notes, today),
|
|
212
|
+
timeline: timelineOf(S.get('Timeline') || []),
|
|
213
|
+
milestones: data && Array.isArray(data.initiatives) ? data.initiatives.filter((x) => x && x.next_milestone).map((x) => ({ id: x.id, title: x.title, ...x.next_milestone })) : [],
|
|
214
|
+
audience: aud ? aud[1] : null, asks: aud ? str(notes['report.asks']).trim() : '',
|
|
215
|
+
reached, notReached,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── the deck ────────────────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
const offPlan = (b) => b.level === 'off' || b.level === 'at-risk';
|
|
221
|
+
const worstWord = (bs) => (bs.some((b) => b.level === 'off') ? 'off track' : 'at risk');
|
|
222
|
+
const kr = (m) => (m ? `${m.anchor} is at ${fmt(m.value)}${m.unit} of its ${fmt(m.target)}${m.unit} target` : '');
|
|
223
|
+
const fmt = (v) => (v == null ? '' : String(Math.round(v * 100) / 100));
|
|
224
|
+
const gapOf = (m) => (m && m.target != null ? Math.round((m.value - m.target) * 100) / 100 : null);
|
|
225
|
+
const gapText = (m) => { const g = gapOf(m); if (g == null) return ''; const u = m.unit === '%' ? ' pts' : m.unit; return (g > 0 ? '+' : g < 0 ? '−' : '±') + fmt(Math.abs(g)) + u; };
|
|
226
|
+
|
|
227
|
+
/** The short answer: who is off plan, in one sentence. */
|
|
228
|
+
function shortAnswer(d) {
|
|
229
|
+
const n = d.blocks.length; const off = d.blocks.filter(offPlan);
|
|
230
|
+
if (!n) return 'No initiative was active in this window';
|
|
231
|
+
if (!off.length) return n === 1 ? `${d.blocks[0].title} is on plan` : `All ${num(n)} initiatives are on plan`;
|
|
232
|
+
if (off.length === 1) return `${cap(num(1))} initiative is ${worstWord(off)}: ${off[0].title}`;
|
|
233
|
+
return `${cap(num(off.length))} of ${num(n)} initiatives are off plan: ${join(off.map((b) => b.title))}`;
|
|
234
|
+
}
|
|
235
|
+
/** The governing thought: the short answer with the worst initiative's progress and KR. */
|
|
236
|
+
function longAnswer(d) {
|
|
237
|
+
const n = d.blocks.length; const off = d.blocks.filter(offPlan);
|
|
238
|
+
if (!off.length) return shortAnswer(d);
|
|
239
|
+
const w = off[0];
|
|
240
|
+
const facts = [w.progress.total ? `${w.title} is ${w.progress.done} of ${w.progress.total} done` : '', w.measure && w.measure.target != null ? kr(w.measure) : ''].filter(Boolean);
|
|
241
|
+
const lead = off.length === 1 ? `${cap(num(1))} of ${num(n)} initiatives is ${worstWord(off)}` : `${cap(num(off.length))} of ${num(n)} initiatives are off plan`;
|
|
242
|
+
return facts.length ? `${lead}: ${facts.join(', and ')}` : `${lead}: ${join(off.map((b) => b.title))}`;
|
|
243
|
+
}
|
|
244
|
+
function supportOf(b) {
|
|
245
|
+
const head = `${b.title} is ${WORD[b.level]}`;
|
|
246
|
+
const p = b.progress;
|
|
247
|
+
const prog = p.total ? `${p.done} of ${p.total} done${p.delta ? `, ${p.delta} more than last report` : ''}.` : '';
|
|
248
|
+
const why = offPlan(b) ? [b.reason ? cap(b.reason) + '.' : '', b.carried.length ? `${b.carried[0].title} was due ${b.carried[0].dueText}.` : ''].filter(Boolean).join(' ') : '';
|
|
249
|
+
return { head, text: why || firstLine(b.note) || prog || cap(b.stage) };
|
|
250
|
+
}
|
|
251
|
+
function askOf(d) {
|
|
252
|
+
if (!d.audience || !d.asks) return null;
|
|
253
|
+
const lines = d.asks.split('\n').map((l) => l.trim()).filter((l) => l && !/^<!--/.test(l));
|
|
254
|
+
const opts = lines.filter((l) => /^([-*]|\d+[.)])\s+/.test(l)).map((l) => plain(l.replace(/^([-*]|\d+[.)])\s+/, ''))).slice(0, 3);
|
|
255
|
+
const rest = lines.filter((l) => !/^([-*]|\d+[.)])\s+/.test(l)).map(plain);
|
|
256
|
+
const by = /\bby (\d{1,2} [A-Z][a-z]{2}(?: \d{4})?|\d{4}-\d{2}-\d{2})/i.exec(d.asks);
|
|
257
|
+
return { question: rest[0] || opts[0] || '', more: rest.slice(1).join(' '), options: rest[0] ? opts : opts.slice(1), who: d.audience, by: by ? (DATE_RE.test(by[1]) ? short(by[1]) : by[1]) : null };
|
|
258
|
+
}
|
|
259
|
+
function timelineTitle(rows, overdue) {
|
|
260
|
+
const dated = rows.map((r) => ({ title: r.title, due: r.spans.reduce((a, s) => (s.due > a ? s.due : a), '') })).filter((r) => r.due).sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : 0));
|
|
261
|
+
let t = '';
|
|
262
|
+
if (dated.length >= 2) {
|
|
263
|
+
const last = dated[dated.length - 1]; const prev = dated[dated.length - 2];
|
|
264
|
+
const days = Math.round((ms(last.due) - ms(prev.due)) / DAY);
|
|
265
|
+
t = days ? `${last.title} lands ${short(last.due)}, ${num(days)} day${days === 1 ? '' : 's'} after ${prev.title}` : `${last.title} and ${prev.title} both land ${short(last.due)}`;
|
|
266
|
+
} else if (dated.length === 1) t = `${dated[0].title} is due ${short(dated[0].due)}`;
|
|
267
|
+
if (overdue.length) t += `${t ? ', with ' : ''}${overdue[0].title} already overdue`;
|
|
268
|
+
return cap(t);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The scorecard's action title: the verdict in one line (brief 4 item 4). The long form names who is on track too; when it would not fit on one
|
|
272
|
+
* line it is "3 of 12 off plan: X, Y and Z", with at most three names ("… and 2 more"). */
|
|
273
|
+
function scorecardTitle(blocks, off, on) {
|
|
274
|
+
const n = blocks.length;
|
|
275
|
+
if (!n) return 'No initiative was active in this window';
|
|
276
|
+
if (!off.length) return n === 1 ? `${blocks[0].title} is on plan and needs no comment` : `All ${num(n)} initiatives are on plan; no row needs a comment`;
|
|
277
|
+
const long = `${off.length === 1 ? `Only ${off[0].title} is` : `${join(off.map((b) => b.title))} are`} off plan${on.length ? `; ${join(on.map((b) => b.title))} ${on.length === 1 ? 'is' : 'are'} on track and need${on.length === 1 ? 's' : ''} no comment` : ''}`;
|
|
278
|
+
if (long.length <= ACTION_MAX) return long;
|
|
279
|
+
const names = off.slice(0, 3).map((b) => b.title).concat(off.length > 3 ? [`${off.length - 3} more`] : []);
|
|
280
|
+
const short1 = `${off.length} of ${n} off plan: ${join(names)}`;
|
|
281
|
+
return short1.length <= ACTION_MAX ? short1 : `${off.length} of ${n} initiatives are off plan`;
|
|
282
|
+
}
|
|
283
|
+
/** The Pyramid deck of a cadence draft, or null when the markdown is not one. */
|
|
284
|
+
function deckOf(markdown, look, extra) {
|
|
285
|
+
const d = readDraft(markdown, extra);
|
|
286
|
+
if (!d) return null;
|
|
287
|
+
const L = { ...STANDARD, ...(look || {}) };
|
|
288
|
+
const slides = [];
|
|
289
|
+
const frozen = d.window ? `Frozen ${human(d.window.to)}` : '';
|
|
290
|
+
// the title is the answer: the first sentence of the PM's headline note; the rest of that paragraph opens the Answer slide (brief 4 item 3)
|
|
291
|
+
const said = sentences(d.headlineNote);
|
|
292
|
+
// a long first sentence keeps its first clause as the title; the clause after it opens the lead, so nothing the PM wrote is lost
|
|
293
|
+
const cut = said[0] && said[0].length > TITLE_FIT ? /^(.{30,}?)(?:,| —| –|;)\s+(?:because |as |so |which |while |but )?(.+)$/.exec(said[0]) : null;
|
|
294
|
+
const head = cut && cut[1].length <= TITLE_FIT ? cut[1].replace(/[,;]$/, '') + '.' : said[0];
|
|
295
|
+
const rest = (cut && cut[1].length <= TITLE_FIT ? [cap(cut[2])] : []).concat(said.slice(1));
|
|
296
|
+
const titleText = clip(head || shortAnswer(d), TITLE_MAX);
|
|
297
|
+
const lead = rest.length ? clip(rest.join(' '), LEAD_MAX) : '';
|
|
298
|
+
const since = sinceWords(d.window);
|
|
299
|
+
const measures = d.blocks.filter((b) => b.measure);
|
|
300
|
+
const krSrc = [...new Set(measures.map((b) => `${b.measure.anchor} as of ${short(b.measure.as_of)} (${b.measure.source})`))];
|
|
301
|
+
const ask = askOf(d);
|
|
302
|
+
|
|
303
|
+
slides.push({
|
|
304
|
+
kind: 'title', tag: [d.name ? (/\breport$/i.test(d.name) ? d.name : `${d.name} report`) : 'Report', d.product].filter(Boolean).join(' · '),
|
|
305
|
+
title: titleText, size: titleText.length > 60 ? 48 : 64,
|
|
306
|
+
sub: [d.slot ? short(d.slot) + ' ' + d.slot.slice(0, 4) : '', d.window ? `window ${short(d.window.from)} to ${short(d.window.to)}${since ? ', ' + since : ''}` : ''].filter(Boolean).join(' · '),
|
|
307
|
+
source: frozen,
|
|
308
|
+
});
|
|
309
|
+
const order = d.blocks.filter(offPlan).concat(d.blocks.filter((b) => !offPlan(b)));
|
|
310
|
+
slides.push({
|
|
311
|
+
kind: 'answer', tag: 'The answer', title: clip(longAnswer(d), TITLE_MAX), lead, supports: order.slice(0, 3).map(supportOf), ask,
|
|
312
|
+
source: ['Source: Headline, initiative blocks', ...krSrc, ask ? 'Needs your call note' : ''].filter(Boolean).join(' · '),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const off = d.blocks.filter(offPlan); const on = d.blocks.filter((b) => b.level === 'on');
|
|
316
|
+
const scTitle = scorecardTitle(d.blocks, off, on);
|
|
317
|
+
const anchor = measures.length && measures.every((b) => b.measure.anchor === measures[0].measure.anchor) ? measures[0].measure.anchor : 'KR';
|
|
318
|
+
const rows = order.map((b) => ({
|
|
319
|
+
title: b.title, stage: b.stage, level: b.level, progress: b.progress, measure: b.measure, gap: gapText(b.measure),
|
|
320
|
+
comment: offPlan(b) ? [b.reason ? cap(b.reason) + '.' : '', firstLine(b.note)].filter(Boolean).join(' ') : '',
|
|
321
|
+
}));
|
|
322
|
+
const pages = [[]]; let used = 0;
|
|
323
|
+
for (const r of rows) {
|
|
324
|
+
const h = rowHeight(r);
|
|
325
|
+
if (pages[pages.length - 1].length && (used + h > ROWS_HEIGHT || pages[pages.length - 1].length >= ROWS_PER_SLIDE)) { pages.push([]); used = 0; }
|
|
326
|
+
pages[pages.length - 1].push(r); used += h;
|
|
327
|
+
}
|
|
328
|
+
pages.forEach((pg, i) => {
|
|
329
|
+
slides.push({ kind: 'scorecard', title: i ? clip(scTitle, ACTION_MAX - 12) + ' (continued)' : scTitle, rows: pg, kr: measures.length ? anchor : null,
|
|
330
|
+
source: ['Source: initiative blocks', ...krSrc].join(' · ') });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const overdue = d.blocks.flatMap((b) => b.carried.filter((c) => c.due).map((c) => ({ ...c, initiative: b.title })));
|
|
334
|
+
// an initiative with an overdue item but no dated span still gets its row: the overdue mark is the finding
|
|
335
|
+
const tl = d.timeline.concat(d.blocks.filter((b) => !d.timeline.some((r) => r.title === b.title) && b.carried.some((c) => c.due)).map((b) => ({ title: b.title, spans: [], holds: [] })));
|
|
336
|
+
if (tl.length) {
|
|
337
|
+
const ids = new Map(d.blocks.map((b) => [b.title, b.id]));
|
|
338
|
+
// the title speaks only of the rows the chart draws (brief 4 item 5)
|
|
339
|
+
const shown = tl.slice(0, 6);
|
|
340
|
+
const shownOverdue = overdue.filter((o) => shown.some((r) => r.title === o.initiative));
|
|
341
|
+
const days = shown.flatMap((r) => r.spans.flatMap((s) => [s.start, s.due])).concat(shownOverdue.map((o) => o.due), d.today ? [d.today] : []).filter(Boolean).sort();
|
|
342
|
+
slides.push({
|
|
343
|
+
kind: 'timeline', title: clip(timelineTitle(shown, shownOverdue), ACTION_MAX + 20), from: days[0], to: days[days.length - 1], today: d.today,
|
|
344
|
+
rows: shown.map((r) => ({ title: r.title, spans: r.spans, holds: r.holds, milestone: d.milestones.find((m) => m.id === ids.get(r.title) || m.title === r.title) || null,
|
|
345
|
+
overdue: overdue.filter((o) => o.initiative === r.title) })),
|
|
346
|
+
source: `Source: dated work items (Timeline section)${d.window ? ', frozen ' + short(d.window.to) : ''}`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (ask && ask.question) {
|
|
350
|
+
slides.push({ kind: 'ask', tag: 'Needs your call', title: ask.by && !/\bby\b/i.test(ask.question) ? `Decide by ${ask.by}: ${ask.question.replace(/\?$/, '')}` : ask.question, ask,
|
|
351
|
+
source: `Source: Needs your call note · for ${d.audience} only` });
|
|
352
|
+
}
|
|
353
|
+
const nr = d.notReached;
|
|
354
|
+
slides.push({
|
|
355
|
+
kind: 'sources',
|
|
356
|
+
title: clip(`Every number comes from ${num(d.reached.length)} source${d.reached.length === 1 ? '' : 's'}${nr.length ? `; ${nr.length > 3 ? `${num(nr.length)} sources` : join(nr.map((x) => x.source))} could not be read, so what ${nr.length === 1 ? 'it holds is' : 'they hold is'} not in this report` : '; every source was reached'}`, TITLE_MAX + 40),
|
|
357
|
+
reached: d.reached, notReached: nr, source: 'Source: Sources reached and not reached',
|
|
358
|
+
});
|
|
359
|
+
slides.forEach((s, i) => { s.n = i + 1; });
|
|
360
|
+
return { slides, look: L, name: d.title };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── the slides as HTML (1280 x 720 frames) ──────────────────────────────────────────────────────────
|
|
364
|
+
const SHAPE = {
|
|
365
|
+
on: (c) => `<svg width="16" height="16" viewBox="0 0 12 12" role="img" aria-label="On track"><circle cx="6" cy="6" r="5" fill="${c.on}"/></svg>`,
|
|
366
|
+
'at-risk': (c) => `<svg width="16" height="16" viewBox="0 0 12 12" role="img" aria-label="At risk"><path d="M6 1 L11.2 10.5 H0.8 Z" fill="${c.risk}"/></svg>`,
|
|
367
|
+
off: (c) => `<svg width="16" height="16" viewBox="0 0 12 12" role="img" aria-label="Off track"><rect x="1" y="1" width="10" height="10" rx="1.5" fill="${c.off}"/></svg>`,
|
|
368
|
+
unknown: (c) => `<svg width="16" height="16" viewBox="0 0 12 12" role="img" aria-label="Unknown"><circle cx="6" cy="6" r="4.3" fill="none" stroke="${c.unknown}" stroke-width="1.6"/></svg>`,
|
|
369
|
+
};
|
|
370
|
+
function css(L) {
|
|
371
|
+
const f = `'${String(L.font).replace(/[^A-Za-z0-9 -]/g, '')}', Nunito, system-ui, sans-serif`;
|
|
372
|
+
return `.pmos-slide.py{width:1280px;height:720px;box-sizing:border-box;background:${L.ground};color:${L.ink};font-family:${f};position:relative;overflow:hidden;display:flex;flex-direction:column}
|
|
373
|
+
.py *{box-sizing:border-box}
|
|
374
|
+
.py.py-navy{background:${L.accent};color:#fff}
|
|
375
|
+
.py .in{flex:1;padding:48px 67px 0;display:flex;flex-direction:column;min-height:0}
|
|
376
|
+
.py .ft{height:48px;padding:0 67px;display:flex;align-items:center;gap:19px;font-size:15px;color:#8a8478;border-top:1px solid ${L.line}}
|
|
377
|
+
.py .ft .s{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
378
|
+
.py.py-navy .ft{border-top-color:rgba(255,255,255,.14);color:#c9d3ea}
|
|
379
|
+
.py .tag{font-weight:700;font-size:15px;line-height:1;letter-spacing:1.9px;text-transform:uppercase;color:${L.muted}}
|
|
380
|
+
.py .ti{font-size:35px;line-height:1.22;font-weight:800;letter-spacing:-.3px;margin:13px 0 10px;max-width:1120px}
|
|
381
|
+
.py .rule{height:3px;width:90px;background:${L.accent};margin:16px 0 26px}
|
|
382
|
+
.py .sm{font-size:18px;color:${L.soft};line-height:1.45}
|
|
383
|
+
.py .sup{display:flex;gap:19px;align-items:flex-start;padding:16px 0;border-top:1px solid ${L.line}}
|
|
384
|
+
.py .sup:first-child{border-top:0}
|
|
385
|
+
.py .sup .n{width:35px;height:35px;border-radius:50%;background:${L.accent};color:#fff;font-size:18px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;flex:none}
|
|
386
|
+
.py .sup b{display:block;font-size:22px;line-height:1.3}.py .sup span{font-size:18px;color:${L.soft}}
|
|
387
|
+
.py .ask{border:3px solid ${L.accent};border-radius:16px;padding:22px 26px;background:${L.ask}}
|
|
388
|
+
.py .ask.alt{border-color:#cfc8ba;background:#fff}
|
|
389
|
+
.py .ask .q{font-size:22px;font-weight:800;line-height:1.3;margin:10px 0}
|
|
390
|
+
.py .tb{display:grid;font-size:17px;align-items:center}
|
|
391
|
+
.py .tb>div{padding:12px 10px;border-bottom:1px solid ${L.line};min-width:0}
|
|
392
|
+
.py .tb .h{font-weight:800;font-size:15px;letter-spacing:1.2px;text-transform:uppercase;color:${L.muted};border-bottom:3px solid #d3ccbf}
|
|
393
|
+
.py .key{display:flex;gap:29px;margin-top:22px;font-size:16px;color:${L.muted};align-items:center}
|
|
394
|
+
.py .key span{display:inline-flex;gap:8px;align-items:center}
|
|
395
|
+
.py .gap-neg{color:${L.off};font-weight:800}.py .gap-pos{color:${L.on};font-weight:800}.py .up{color:${L.on}}
|
|
396
|
+
.py .cols{display:flex;gap:48px}.py .cols>div{flex:1;min-width:0}
|
|
397
|
+
.py .miss b{display:block;font-size:21px}.py .miss .tag{color:${L.off}}`;
|
|
398
|
+
}
|
|
399
|
+
function footer(s) { return `<div class="ft"><span class="s">${esc(s.source)}</span><span>${s.n}</span></div>`; }
|
|
400
|
+
function bar(done, total, L) {
|
|
401
|
+
const w = total ? Math.round((154 * done) / total * 10) / 10 : 0;
|
|
402
|
+
return `<svg width="154" height="22" viewBox="0 0 154 22" role="img" aria-label="${done} of ${total} done"><rect x="0" y="5" width="154" height="13" rx="6.5" fill="${L.track}"/><rect x="0" y="5" width="${w}" height="13" rx="6.5" fill="${L.accent}"/></svg>`;
|
|
403
|
+
}
|
|
404
|
+
function bullet(m, L) {
|
|
405
|
+
if (!m || m.target == null) return m ? `<b>${esc(fmt(m.value) + m.unit)}</b>` : '';
|
|
406
|
+
const max = Math.max(m.value, m.target, 1) * (m.unit === '%' && Math.max(m.value, m.target) <= 100 ? 100 / Math.max(m.value, m.target, 1) : 1.25);
|
|
407
|
+
const x = (v) => Math.round((240 * Math.max(0, v)) / max * 10) / 10;
|
|
408
|
+
const g = gapOf(m);
|
|
409
|
+
return `<svg width="240" height="34" viewBox="0 0 240 34" role="img" aria-label="${esc(`${fmt(m.value)}${m.unit} against a target of ${fmt(m.target)}${m.unit}`)}"><rect x="0" y="13" width="240" height="10" rx="5" fill="${L.track}"/><rect x="0" y="10" width="${x(m.value)}" height="16" rx="3" fill="${L.accent}"/><rect x="${Math.min(x(m.target), 236)}" y="3" width="4" height="29" fill="none" stroke="${L.ink}" stroke-width="2.2"/></svg>`
|
|
410
|
+
+ `<div style="font-size:15px;margin-top:-2px"><b>${esc(fmt(m.value) + m.unit)}</b> of ${esc(fmt(m.target) + m.unit)}${g ? ` · <span class="${g < 0 ? 'gap-neg' : 'gap-pos'}">${esc(gapText(m))}</span>` : ''}</div>`;
|
|
411
|
+
}
|
|
412
|
+
function timelineSvg(s, L) {
|
|
413
|
+
const W = 1146; const left = 240; const right = W - 110; const from = ms(s.from); const to = Math.max(ms(s.to), from + DAY);
|
|
414
|
+
const x = (d) => Math.round((left + ((ms(d) - from) / (to - from)) * (right - left)) * 10) / 10;
|
|
415
|
+
const rowH = 70; const top = 44; const H = top + s.rows.length * rowH + 30;
|
|
416
|
+
const out = [];
|
|
417
|
+
const step = Math.max(1, Math.ceil((to - from) / DAY / 6));
|
|
418
|
+
for (let t = from; t <= to; t += step * DAY) { const d = new Date(t).toISOString().slice(0, 10); out.push(`<line x1="${x(d)}" y1="${top - 8}" x2="${x(d)}" y2="${H - 22}" stroke="${L.line}"/><text x="${x(d)}" y="22" font-size="15" fill="#8a8478" text-anchor="middle">${esc(short(d))}</text>`); }
|
|
419
|
+
s.rows.forEach((r, i) => {
|
|
420
|
+
const y = top + i * rowH;
|
|
421
|
+
out.push(`<text x="0" y="${y + 22}" font-size="18" font-weight="800" fill="${L.ink}">${esc(r.title.length > 24 ? r.title.slice(0, 23) + '…' : r.title)}</text>`);
|
|
422
|
+
for (const h of r.holds) out.push(`<rect x="${x(h.from)}" y="${y + 6}" width="${Math.max(4, x(h.to || s.to) - x(h.from))}" height="32" rx="6" fill="${L.track}"/>`);
|
|
423
|
+
for (const sp of r.spans) {
|
|
424
|
+
const w = Math.max(6, x(sp.due) - x(sp.start));
|
|
425
|
+
out.push(`<rect x="${x(sp.start)}" y="${y + 6}" width="${w}" height="32" rx="6" fill="${L.accent}"/>`);
|
|
426
|
+
if (sp.stage && w > 60) out.push(`<text x="${x(sp.start) + 11}" y="${y + 28}" font-size="16" fill="#fff" font-weight="700">${esc(sp.stage)}</text>`);
|
|
427
|
+
}
|
|
428
|
+
const due = r.spans.reduce((a, sp) => (sp.due > a ? sp.due : a), '');
|
|
429
|
+
const m = r.milestone && DATE_RE.test(str(r.milestone.due)) ? r.milestone.due : due;
|
|
430
|
+
if (m) out.push(`<path d="M${x(m) + 13} 0 l10 10 l-10 10 l-10 -10 z" transform="translate(0 ${y + 12})" fill="none" stroke="${L.ink}" stroke-width="2.6"/><text x="${x(m) + 30}" y="${y + 28}" font-size="16" fill="${L.muted}" font-weight="700">${esc(short(m))}</text>`);
|
|
431
|
+
r.overdue.slice(0, 1).forEach((o) => out.push(`<rect x="${x(o.due) - 7}" y="${y + 46}" width="14" height="14" rx="2.5" fill="${L.off}"/><text x="${x(o.due) + 14}" y="${y + 58}" font-size="16" fill="${L.off}" font-weight="700">${esc(`${o.title}, due ${short(o.due)}, overdue`)}</text>`));
|
|
432
|
+
});
|
|
433
|
+
if (s.today) out.push(`<line x1="${x(s.today)}" y1="${top - 12}" x2="${x(s.today)}" y2="${H - 22}" stroke="${L.today}" stroke-width="2.4" stroke-dasharray="6 5"/><text x="${x(s.today)}" y="${H - 4}" font-size="15" fill="${L.today}" font-weight="800" text-anchor="middle">Today</text>`);
|
|
434
|
+
return `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(`Timeline, ${short(s.from)} to ${short(s.to)}${s.today ? ', today is ' + short(s.today) : ''}`)}" style="display:block;font-family:inherit">${out.join('')}</svg>`;
|
|
435
|
+
}
|
|
436
|
+
function slideHtml(s, L) {
|
|
437
|
+
const open = (cls) => `<section class="pmos-slide py${cls ? ' ' + cls : ''}" data-title="${esc(s.title)}" data-kind="${s.kind}">`;
|
|
438
|
+
if (s.kind === 'title') {
|
|
439
|
+
return open('py-navy') + `<div class="in" style="justify-content:flex-end;padding-bottom:54px"><div class="tag" style="color:#c9d3ea">${esc(s.tag)}</div>`
|
|
440
|
+
+ `<div style="font-size:${s.size || 64}px;font-weight:800;line-height:1.1;margin:16px 0;max-width:1100px">${esc(s.title)}</div><div style="font-size:22px;color:#dde4f3;max-width:900px;line-height:1.5">${esc(s.sub)}</div></div>${footer(s)}</section>`;
|
|
441
|
+
}
|
|
442
|
+
const head = (tag) => `${tag ? `<div class="tag">${esc(tag)}</div>` : ''}<h3 class="ti">${esc(s.title)}</h3><div class="rule"></div>`;
|
|
443
|
+
let body = '';
|
|
444
|
+
if (s.kind === 'answer') {
|
|
445
|
+
const sup = s.supports.map((x, i) => `<div class="sup"><span class="n">${i + 1}</span><div><b>${esc(x.head)}</b><span>${esc(x.text)}</span></div></div>`).join('');
|
|
446
|
+
const ask = s.ask && s.ask.question ? `<div style="flex:1"><div class="ask"><div class="tag" style="color:${L.accent}">Your call</div><div class="q">${esc(s.ask.question)}</div><div class="sm">For ${esc(s.ask.who)}${s.ask.by ? ` · by ${esc(s.ask.by)}` : ''}</div></div></div>` : '';
|
|
447
|
+
body = (s.lead ? `<p class="sm" style="margin:-8px 0 14px;max-width:1120px">${esc(s.lead)}</p>` : '') + `<div style="display:flex;gap:42px;flex:1;min-height:0"><div style="flex:1.45">${sup}</div>${ask}</div>`;
|
|
448
|
+
} else if (s.kind === 'scorecard') {
|
|
449
|
+
const cols = s.kr ? '220px 120px 80px 230px 270px 1fr' : '260px 140px 90px 260px 1fr';
|
|
450
|
+
const hd = ['Initiative', 'Stage', 'Status', 'Done of total'].concat(s.kr ? [`${s.kr} · now vs target`] : [], ['Why, what we do']);
|
|
451
|
+
const cells = s.rows.map((r) => [`<div style="font-weight:800">${esc(r.title)}</div>`, `<div>${esc(r.stage)}</div>`, `<div>${(SHAPE[r.level] || SHAPE.unknown)(L)}</div>`,
|
|
452
|
+
`<div style="display:flex;align-items:center;gap:12px">${r.progress.total ? `${bar(r.progress.done, r.progress.total, L)}<span style="white-space:nowrap"><b>${r.progress.done} of ${r.progress.total}</b>${r.progress.delta ? ` <span class="up">+${r.progress.delta}</span>` : ''}</span>` : `<span class="sm">${esc(r.progress.label || 'no breakdown')}</span>`}</div>`]
|
|
453
|
+
.concat(s.kr ? [`<div>${bullet(r.measure, L)}</div>`] : [], [`<div class="sm">${esc(r.comment)}</div>`]).join('')).join('');
|
|
454
|
+
const key = `<div class="key"><span>${SHAPE.on(L)} on track</span><span>${SHAPE['at-risk'](L)} at risk</span><span>${SHAPE.off(L)} off track</span>${s.kr ? `<span><svg width="42" height="13" aria-hidden="true"><rect y="1" width="42" height="10" rx="3" fill="${L.accent}"/></svg> now</span><span><svg width="13" height="19" aria-hidden="true"><rect x="2" y="2" width="5" height="15" fill="none" stroke="${L.ink}" stroke-width="2.2"/></svg> target</span><span>Gap is now minus target</span>` : ''}</div>`;
|
|
455
|
+
body = `<div class="tb" style="grid-template-columns:${cols}">${hd.map((h) => `<div class="h">${esc(h)}</div>`).join('')}${cells}</div>${key}`;
|
|
456
|
+
} else if (s.kind === 'timeline') {
|
|
457
|
+
body = timelineSvg(s, L) + `<div class="key"><span><svg width="42" height="13" aria-hidden="true"><rect width="42" height="13" rx="3" fill="${L.accent}"/></svg> work with dates</span><span><svg width="19" height="19" aria-hidden="true"><path d="M9.5 2 l7.5 7.5 l-7.5 7.5 l-7.5 -7.5 z" fill="none" stroke="${L.ink}" stroke-width="2.6"/></svg> due date</span><span><svg width="16" height="16" aria-hidden="true"><rect width="14" height="14" rx="2.5" fill="${L.off}"/></svg> overdue</span></div>`;
|
|
458
|
+
} else if (s.kind === 'ask') {
|
|
459
|
+
const a = s.ask;
|
|
460
|
+
const opts = a.options.map((o, i) => `<div class="ask${i ? ' alt' : ''}" style="flex:1"><div class="tag">Option ${'ABC'[i]}</div><div class="q">${esc(o)}</div></div>`).join('');
|
|
461
|
+
body = (opts ? `<div style="display:flex;gap:26px">${opts}</div>` : '') + (a.more ? `<p class="sm" style="margin:${opts ? '22px' : '0'} 0 0">${esc(a.more)}</p>` : '')
|
|
462
|
+
+ `<div class="sm" style="display:flex;gap:45px;margin-top:29px"><span><b>Who</b> ${esc(a.who)}</span>${a.by ? `<span><b>By</b> ${esc(a.by)}</span>` : ''}</div>`;
|
|
463
|
+
} else if (s.kind === 'sources') {
|
|
464
|
+
const reached = s.reached.map((x) => `<div class="sup"><span class="sm">${esc(x)}</span></div>`).join('') || '<div class="sup"><span class="sm">None</span></div>';
|
|
465
|
+
const miss = s.notReached.map((x) => `<div class="sup"><div><b>${esc(x.source)}</b><span>${esc(cap(x.why))}${x.why ? '.' : ''}</span></div></div>`).join('');
|
|
466
|
+
body = `<div class="cols"><div><div class="tag">Reached</div>${reached}</div>${miss ? `<div class="miss"><div class="tag">What we don’t know yet</div>${miss}</div>` : ''}</div>`;
|
|
467
|
+
}
|
|
468
|
+
return open('') + `<div class="in">${head(s.tag)}${body}</div>${footer(s)}</section>`;
|
|
469
|
+
}
|
|
470
|
+
/** The deck as one HTML string (the viewer's frames and the PDF print). */
|
|
471
|
+
function deckHtml(deck) {
|
|
472
|
+
if (!deck) return '';
|
|
473
|
+
const L = { ...STANDARD, ...(deck.look || {}) };
|
|
474
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${esc(deck.name)}</title><style>${css(L)}</style></head><body>${deck.slides.map((s) => slideHtml(s, L)).join('')}</body></html>`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── the look ────────────────────────────────────────────────────────────────────────────────────────
|
|
478
|
+
function lum(hex) {
|
|
479
|
+
const h = hex.length === 4 ? hex.slice(1).split('').map((c) => c + c).join('') : hex.slice(1);
|
|
480
|
+
const ch = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
|
481
|
+
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
|
|
482
|
+
}
|
|
483
|
+
const hex6 = (k) => (k.length === 4 ? '#' + k.slice(1).split('').map((c) => c + c).join('') : k).toLowerCase();
|
|
484
|
+
/** A colour white text can sit on (4.5:1) that is a colour, not a grey. */
|
|
485
|
+
function isAccent(k) { const h = hex6(k).slice(1); const v = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); return lum(hex6(k)) <= 0.18 && Math.max(...v) - Math.min(...v) >= 16; }
|
|
486
|
+
const TITLE_SEL = /data-(?:kind|slide-id)\s*=\s*["']?title\b|\.(?:title|cover|hero)(?:-slide)?\b|(?:^|[\s,>])h1\b/i;
|
|
487
|
+
const PRIMARY_VAR = /--(?:primary|brand|accent|title|main|navy|heading)[\w-]*\s*:\s*(#[0-9a-f]{6}|#[0-9a-f]{3})\b/gi;
|
|
488
|
+
/** The colour a design names as its own: a --primary/--brand/--accent/--title variable, else the background (then the text colour) of its title
|
|
489
|
+
* slide or main heading. Only a colour that passes isAccent. */
|
|
490
|
+
function primaryOf(t) {
|
|
491
|
+
for (const m of t.matchAll(PRIMARY_VAR)) if (isAccent(m[1])) return hex6(m[1]);
|
|
492
|
+
const rules = [...t.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => TITLE_SEL.test(m[1]));
|
|
493
|
+
for (const prop of [/(?:^|;)\s*background(?:-color)?\s*:[^;]*?(#[0-9a-f]{6}|#[0-9a-f]{3})\b/i, /(?:^|;)\s*color\s*:\s*(#[0-9a-f]{6}|#[0-9a-f]{3})\b/i]) {
|
|
494
|
+
for (const r of rules) { const c = prop.exec(r[2]); if (c && isAccent(c[1])) return hex6(c[1]); }
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
/** The font and accent a saved design's style uses, or null. Pure: css = the render's style text. */
|
|
499
|
+
function lookFromCss(cssText) {
|
|
500
|
+
const t = str(cssText);
|
|
501
|
+
if (!t) return null;
|
|
502
|
+
const fm = /font-family\s*:\s*([^;}]+)/i.exec(t);
|
|
503
|
+
const font = fm ? fm[1].split(',')[0].trim().replace(/^['"]|['"]$/g, '').replace(/[^A-Za-z0-9 -]/g, '').slice(0, 60) : '';
|
|
504
|
+
const count = new Map();
|
|
505
|
+
for (const m of t.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { const k = '#' + m[1].toLowerCase(); count.set(k, (count.get(k) || 0) + 1); }
|
|
506
|
+
const dark = [...count.entries()].filter(([k]) => isAccent(k)).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
|
|
507
|
+
const out = {};
|
|
508
|
+
if (font && !/^(inherit|initial|var)/i.test(font)) out.font = font;
|
|
509
|
+
// the look's own title or primary colour first (live check of D3: navy #1f4e79, not the most used slate); the most used dark colour after it
|
|
510
|
+
const named = primaryOf(t);
|
|
511
|
+
if (named) out.accent = named;
|
|
512
|
+
else if (dark.length) out.accent = dark[0][0];
|
|
513
|
+
return Object.keys(out).length ? out : null;
|
|
514
|
+
}
|
|
515
|
+
/** The look of a report: its saved design D<n> (read through report-design.js), else the standard PMOS look. Never throws. */
|
|
516
|
+
function lookOf(root, cadence) {
|
|
517
|
+
try {
|
|
518
|
+
const D = require('./report-design.js');
|
|
519
|
+
const st = D.stateOf(root, cadence);
|
|
520
|
+
if (!st.saved) return { ...STANDARD };
|
|
521
|
+
const n = st.saved.version;
|
|
522
|
+
const html = D.versionHtml(root, cadence, n);
|
|
523
|
+
const styles = [...str(html).matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1])
|
|
524
|
+
// the title slide's own inline style counts as a title rule
|
|
525
|
+
.concat([...str(html).matchAll(/<section\b[^>]*data-(?:kind|slide-id)="title"[^>]*\bstyle="([^"]*)"/gi)].map((m) => `[data-kind=title]{${m[1]}}`)).join('\n');
|
|
526
|
+
return { ...STANDARD, ...(lookFromCss(styles) || {}), name: `Look D${n}`, version: n };
|
|
527
|
+
} catch { return { ...STANDARD }; }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The cycle's numbers next to a structured draft (<slot>.data.json), or null. abs = the draft's absolute path. */
|
|
531
|
+
function dataOf(abs) {
|
|
532
|
+
try { const o = JSON.parse(require('fs').readFileSync(String(abs).replace(/\.md$/, '.data.json'), 'utf8')); return isObjP(o) ? o : null; } catch { return null; }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
module.exports = { STANDARD, readDraft, deckOf, deckHtml, lookOf, lookFromCss, dataOf, short, fromShort, sentences, clip };
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* THE LINES of structure.jsonl. Every line is { ev, …, at, actor }. `actor` is the guard's actor for a press, or { by: 'pmos', via: 'job' } for a job:
|
|
22
22
|
* describe {text} · turn {text, base, job} · version {n, turn, answer, questions?, options?, from?} · accept {n, charts: [ids]} · revert {n}
|
|
23
|
+
* unsave {} (Go back to a template version that had the standard sections: nothing is saved; lib/routes-report-flow.js restore)
|
|
23
24
|
* source {n, source} (the new source, with the version it made) · remove {n, kind, ref} · dry {run, n, ok} · check {source, ok, steps} · error {text, mode}
|
|
24
25
|
*
|
|
25
26
|
* FOLD. { description, log: [{ who: pm|pmos, text, at, n?, questions, options }], versions: Map n → meta, current, saved: { n, at } | null, dry: {run, n, at, ok} | null,
|
|
@@ -37,7 +38,7 @@ const crypto = require('crypto');
|
|
|
37
38
|
const RD = require('./report-draft.js');
|
|
38
39
|
const CS = require('./charts-store.js');
|
|
39
40
|
|
|
40
|
-
const EVENTS = Object.freeze(['describe', 'turn', 'version', 'accept', 'revert', 'source', 'remove', 'dry', 'pull', 'check', 'error']);
|
|
41
|
+
const EVENTS = Object.freeze(['describe', 'turn', 'version', 'accept', 'revert', 'unsave', 'source', 'remove', 'dry', 'pull', 'check', 'error']);
|
|
41
42
|
const PMOS_KINDS = Object.freeze(['pmos-level', 'dashboard', 'chart', 'document', 'past-report']);
|
|
42
43
|
const OUTSIDE_KINDS = Object.freeze(['connector', 'link', 'upload', 'person', 'chrome', 'computer']);
|
|
43
44
|
const KINDS = Object.freeze([...PMOS_KINDS, ...OUTSIDE_KINDS]);
|
|
@@ -123,6 +124,9 @@ function fold(events) {
|
|
|
123
124
|
} else if (l.ev === 'revert') {
|
|
124
125
|
if (st.versions.has(l.n)) st.current = l.n;
|
|
125
126
|
st.log.push({ who: 'pm', text: `Back to S${l.n}. The next turn builds on it.`, at: l.at, n: l.n });
|
|
127
|
+
} else if (l.ev === 'unsave') {
|
|
128
|
+
st.saved = null;
|
|
129
|
+
st.log.push({ who: 'pm', text: 'Back to the standard sections (a template went back).', at: l.at });
|
|
126
130
|
} else if (l.ev === 'source') {
|
|
127
131
|
st.log.push({ who: 'pm', text: `Added the source ${str(l.source && l.source.name) || str(l.source && l.source.id)}.`, at: l.at });
|
|
128
132
|
} else if (l.ev === 'remove') {
|