create-pmos 0.197.0 → 0.199.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 +1 -1
- 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/c3-report-design-real-test.js +225 -0
- package/workspace/c3-report-design-test.js +403 -0
- package/workspace/c3-report-setup-test.js +3 -2
- package/workspace/c3-work-test.js +1 -1
- package/workspace/guide/core-documents.md +1 -1
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/report-design.md +104 -0
- package/workspace/guide/report-slides.md +3 -1
- package/workspace/guide/reports-set-up.md +2 -1
- package/workspace/lib/headless.js +24 -6
- package/workspace/lib/report-design.js +741 -0
- package/workspace/lib/report-draft.js +8 -3
- package/workspace/lib/report-setups.js +1 -1
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-core-docs.js +18 -11
- package/workspace/lib/routes-doc-sheet.js +8 -1
- package/workspace/lib/routes-report-design.js +481 -0
- package/workspace/lib/routes-reports.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-rd.js +426 -0
- package/workspace/public/pm/c3-demo-rep2.js +2 -0
- package/workspace/public/pm/c3-demo-rs.js +5 -1
- package/workspace/public/pm/c3-design-studio.js +308 -0
- package/workspace/public/pm/c3-doc-formats.js +3 -2
- package/workspace/public/pm/c3-doc-sheet.js +30 -5
- package/workspace/public/pm/c3-initiatives.js +2 -1
- package/workspace/public/pm/c3-report-deck.css +139 -0
- package/workspace/public/pm/c3-report-deck.js +558 -0
- package/workspace/public/pm/c3-report-frame.css +20 -0
- package/workspace/public/pm/c3-report-setup.js +18 -6
- package/workspace/server.js +17 -5
- package/workspace/skills/report-design.skill +61 -0
- package/workspace/test-lists/report-design-backend.json +5 -0
- package/workspace/test-lists/report-design-studio-ui.json +5 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.199.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.199.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/* pm/c3-demo-rd.js — DEMO answers for the report design studio (initiative report-design-studio) on /c3.html?demo=1. Real mode never loads this file.
|
|
2
|
+
* c3-demo-rs.js installs it with the set-ups and documents it already holds, and answers every route of report-design-contract.md from here:
|
|
3
|
+
*
|
|
4
|
+
* GET /api/report-design, /version (text/html), /cycle, /cycle-html (text/html) GET /api/report-notes
|
|
5
|
+
* POST /api/report-design/describe|sample|turn|accept|revert|cycle-decision POST /api/report-notes/speaker|pin
|
|
6
|
+
*
|
|
7
|
+
* The data is the mockup's: the MySPH & Identity Platform biweekly update (set-up rs1) has a saved deck design, versions D1 to D3 made from
|
|
8
|
+
* the Wed 21 Oct draft, two samples, a conversation, two designed cycles (Wed 21 Oct differs: Usage-based billing got its own slide; Wed 7 Oct
|
|
9
|
+
* matches), speaker notes carried forward from 7 Oct and four notes on the slides. The Monthly OOH x Digital review (rs3) has a document
|
|
10
|
+
* design in progress (D1, D2, not saved). Any other set-up starts with no design. A turn runs 1.5 seconds and lands the next version; a turn
|
|
11
|
+
* whose words say "fail" lands an error line instead. The renders follow the HTML contract (one <style>, pmos-slide or pmos-sec sections, the
|
|
12
|
+
* data attributes); their colours are the slide tokens the frame links. Nothing is saved anywhere and nothing is sent. */
|
|
13
|
+
|
|
14
|
+
const json = (b, status) => new Response(JSON.stringify(b), { status: status || 200, headers: { 'content-type': 'application/json' } });
|
|
15
|
+
const html = (t) => new Response(t, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
16
|
+
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
17
|
+
const clone = (x) => JSON.parse(JSON.stringify(x));
|
|
18
|
+
const nowIso = () => new Date().toISOString();
|
|
19
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
20
|
+
|
|
21
|
+
/* ── the deck's look: one <style>, slide tokens only ── */
|
|
22
|
+
const DECK_CSS = [
|
|
23
|
+
'.pmos-slide{position:relative;width:1280px;height:720px;box-sizing:border-box;overflow:hidden;background:var(--slide-bg);color:var(--slide-fg);font:400 18px/26px var(--sans)}',
|
|
24
|
+
'.s-hd{position:absolute;left:48px;right:48px;top:34px;height:56px;display:flex}',
|
|
25
|
+
'.s-num{width:56px;display:grid;place-items:center;font:700 22px var(--sans);color:var(--slide-brand);border:2px solid var(--slide-brand);box-sizing:border-box}',
|
|
26
|
+
'.s-band{flex:1;display:flex;align-items:center;padding:0 20px;background:var(--slide-brand);color:var(--slide-bg);font:700 28px var(--sans)}',
|
|
27
|
+
'.plain .s-band{background:none;color:var(--slide-fg);border-bottom:2px solid var(--slide-border)}.plain .s-num{border:0;color:var(--slide-muted)}',
|
|
28
|
+
'.s-body{position:absolute;left:48px;right:48px;top:118px;bottom:64px}.dense .s-body{top:104px;bottom:52px}',
|
|
29
|
+
'.s-ft{position:absolute;left:48px;right:48px;bottom:20px;display:flex;justify-content:space-between;border-top:1px solid var(--slide-rule);padding-top:8px;font:400 12px var(--mono);color:var(--slide-foot)}',
|
|
30
|
+
'.k-title{background:var(--slide-brand);color:var(--slide-bg)}.k-title.plain{background:var(--slide-bg);color:var(--slide-fg)}',
|
|
31
|
+
'.t-in{position:absolute;left:80px;right:80px;top:160px;display:grid;gap:10px;font:400 22px/32px var(--sans)}.t-in h1{margin:6px 0 18px;font:700 58px/66px var(--sans)}',
|
|
32
|
+
'.eb{font:600 16px/24px var(--mono);letter-spacing:1.5px;text-transform:uppercase;opacity:.75}',
|
|
33
|
+
'.k-title .s-ft{border-top-color:var(--slide-border);color:var(--slide-border)}.k-title.plain .s-ft{color:var(--slide-foot)}',
|
|
34
|
+
'.hl{margin:0;font:600 30px/40px var(--sans)}.hlg{display:grid;grid-template-columns:1fr 1fr;gap:28px;margin-top:26px}',
|
|
35
|
+
'.kpis{display:grid;gap:16px}.kpi{display:grid;grid-template-columns:150px 1fr auto;align-items:baseline;gap:16px;padding:20px;border:1px solid var(--slide-border)}',
|
|
36
|
+
'.kpi b{font:700 40px/48px var(--sans);color:var(--slide-primary)}.kpi span{color:var(--slide-muted)}.up{color:var(--slide-done);font-weight:600}.dn{color:var(--slide-overdue);font-weight:600}',
|
|
37
|
+
'.chart{padding:14px 18px;border:1px solid var(--slide-border)}.chart p{margin:0;font:600 14px var(--sans);color:var(--slide-muted)}',
|
|
38
|
+
'.chart svg{display:block;width:100%;height:auto}.chart polyline{fill:none;stroke:var(--slide-primary);stroke-width:3}.chart circle{fill:var(--slide-bg);stroke:var(--slide-primary);stroke-width:3}',
|
|
39
|
+
'.chart text{font:13px var(--sans);fill:var(--slide-muted);text-anchor:middle}.ax{stroke:var(--slide-border)}',
|
|
40
|
+
'.tbl{display:grid;border:1px solid var(--slide-border)}.tr{display:grid;grid-template-columns:2.2fr 1.3fr 1.6fr 1fr;gap:12px;padding:12px 16px;border-top:1px solid var(--slide-border);align-items:center}',
|
|
41
|
+
'.tr:first-child{border-top:0;font:600 13px var(--mono);color:var(--slide-muted);text-transform:uppercase}.tr.dec{grid-template-columns:2fr 1fr 2fr 1fr}',
|
|
42
|
+
'.badge{font:600 14px var(--mono);text-transform:uppercase}.badge.risk{color:var(--slide-overdue)}.sm{font:14px var(--sans);color:var(--slide-muted)}',
|
|
43
|
+
'.cols{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:20px}.col{border:1px solid var(--slide-border);padding:16px 20px}',
|
|
44
|
+
'.col h3{margin:0 0 10px;font:700 20px var(--sans);display:flex;justify-content:space-between}.col h3 span{font:400 14px var(--mono);color:var(--slide-muted)}',
|
|
45
|
+
'.col ul{margin:0;padding-left:20px}.col li{margin:4px 0}.more{display:inline-block;margin-top:6px;color:var(--slide-primary);font-weight:600}',
|
|
46
|
+
'.top{display:flex;gap:24px;align-items:center}.live{color:var(--slide-primary);font:600 14px var(--sans)}.prog{flex:1}.prog svg{display:block;width:100%;height:18px}',
|
|
47
|
+
'.trk{fill:var(--slide-track)}.fil{fill:var(--slide-primary)}.pct{font:700 24px var(--sans)}',
|
|
48
|
+
'.note{margin-top:18px;padding:12px 16px;border-left:4px solid var(--slide-note-warning);background:var(--slide-secondary)}.note.info{border-left-color:var(--slide-note-info)}',
|
|
49
|
+
'.rm svg{display:block;width:100%;height:auto}.rm text{font:15px var(--sans);fill:var(--slide-fg)}.rm .g{font:700 13px var(--mono);fill:var(--slide-muted)}.rm .m{fill:var(--slide-muted);text-anchor:middle}',
|
|
50
|
+
'.rm .b-spec{fill:var(--slide-ph-spec)}.rm .b-build{fill:var(--slide-ph-build)}.rm .b-eval{fill:var(--slide-ph-evaluate)}.rm .b-hand{fill:var(--slide-ph-handoff)}.rm .b-intake{fill:var(--slide-ph-intake)}.rm .grid{stroke:var(--slide-border)}',
|
|
51
|
+
'.tl{list-style:none;margin:0;padding:0;display:grid;gap:18px}.tl li{display:grid;grid-template-columns:150px 1fr;gap:16px;align-items:baseline}',
|
|
52
|
+
'.pill{font:600 13px var(--mono);text-transform:uppercase;padding:2px 10px;background:var(--slide-launch-bg);color:var(--slide-launch)}.pill.fix{background:var(--slide-fix-bg);color:var(--slide-fix)}.pill.improvement{background:var(--slide-improvement-bg);color:var(--slide-improvement)}',
|
|
53
|
+
'.risk svg{display:block;width:100%;height:auto}.risk text{font:16px var(--sans);fill:var(--slide-fg)}.risk .v{fill:var(--slide-muted);font-weight:600}.i1{fill:var(--slide-done)}.i2{fill:var(--slide-note-warning)}.i3{fill:var(--slide-overdue)}',
|
|
54
|
+
].join('\n');
|
|
55
|
+
const FOOT_C = 'IDPF | Biweekly · 8 Oct → 21 | 21 October 2026';
|
|
56
|
+
const FOOT_P = 'IDPF | Biweekly · 24 Sep → 7 Oct | 7 October 2026';
|
|
57
|
+
|
|
58
|
+
/* ── the facts of the two designed cycles (the mockup's CY) ── */
|
|
59
|
+
const I = (t, sub, done) => ({ t, sub, done });
|
|
60
|
+
const CY = {
|
|
61
|
+
c: { foot: FOOT_C, lastR: '8 Oct → 21', nextR: '22 Oct → 4 Nov', head: 'Sandbox keys are live for every partner. The Onboarding v2 beta moves 5 days, to Mon 26 Oct.',
|
|
62
|
+
kpis: [['97.4%', 'Login success', '+0.6 pts', 'metric:login-success', '96.8%'], ['71.2%', 'Signup completion', '-1.1 pts', 'metric:signup-completion', '72.3%'], ['461', 'Active integrations', '+11.9%', 'jira:IDPF-partner-filter', '412']],
|
|
63
|
+
series: [['12 Aug', 96.1], ['26 Aug', 96.5], ['9 Sep', 96.9], ['23 Sep', 95.2], ['7 Oct', 96.8], ['21 Oct', 97.4]],
|
|
64
|
+
risks: [['Onboarding v2 beta slips again', 3], ['Partner API 429s on two partners', 2], ['Activation down 6% week on week, trial cohort only', 2]],
|
|
65
|
+
needs: ['A QA tester for one sprint, from Payments (Eko)', 'The partner list for the beta invite, from Sales (Lina)'],
|
|
66
|
+
rel: [['launch', 'Self-serve sandbox keys for every partner', 'Tue 6 Oct', 'commit:a41c9e2'], ['improvement', 'Clearer login error messages', 'Thu 8 Oct', 'jira:IDPF-44'], ['fix', 'Retry time on Partner API 429 responses', 'Wed 14 Oct', 'jira:IDPF-88']],
|
|
67
|
+
dec: [['Approve the scope cut for Onboarding v2 beta', 'Ana Putri', 'Cut the analytics events from the beta', 'Fri 23 Oct'], ['Choose the rate-limit approach', 'Wawan', 'Option A: soft limits first', 'Fri 23 Oct']],
|
|
68
|
+
inits: {
|
|
69
|
+
'IDPF-12': { title: 'Self-serve onboarding v2', stage: 'Build', status: 'at risk', pct: 62, was: 48, ms: 'Beta opens · 26 Oct 26', last: [I('Guided setup, steps 1 to 3', 'Ana Putri', 1), I('Sandbox key shown in the flow', 'Budi Santoso', 1), I('Copy review', 'Budi Santoso', 1), I('Empty states', 'Ana Putri', 1), I('Error states', 'Ana Putri', 1), I('QA test plan', 'Sari Wijaya', 1), I('Analytics events', 'Chen Wei', 0)], next: [I('Beta opens, Mon 26 Oct', 'Ana Putri'), I('Scope cut for the beta', 'Ana Putri'), I('Beta invite list', 'Lina Kusuma'), I('Support playbook', 'Sari Wijaya')], note: 'Beta moves 5 days: QA capacity was down for one sprint. The scope cut needs a decision by Fri 23 Oct.' },
|
|
70
|
+
'IDPF-07': { title: 'Partner API rate limits', stage: 'Spec', status: 'on track', pct: 25, was: 15, ms: 'Approach chosen · 23 Oct 26', last: [I('Options paper drafted', 'Wawan', 1), I('Partner traffic analysis', 'Chen Wei', 1), I('Retry time on 429 responses', 'Ana Putri', 1)], next: [I('Approach chosen, Fri 23 Oct', 'Wawan'), I('Soft-limit alert design', 'Budi Santoso'), I('Partner notice drafted', 'Wawan')], note: 'Option A recommended: soft limits first, hard limits in release 3.2.' },
|
|
71
|
+
'IDPF-21': { title: 'Usage-based billing', stage: 'Intake', status: 'on track', pct: null, was: null, ms: 'Interviews done · 6 Nov 26', last: [I('Five customer interviews booked', 'Wawan', 1)], next: [I('Interviews done, Fri 6 Nov', 'Wawan'), I('Metering options listed', 'Chen Wei')], note: '' },
|
|
72
|
+
'IDPF-31': { title: 'Sandbox keys for every partner', stage: 'Handoff', status: 'shipped', pct: 100, ms: '' } } },
|
|
73
|
+
p: { foot: FOOT_P, lastR: '24 Sep → 7 Oct', nextR: '8 Oct → 21', head: 'The sandbox keys beta is out. Rate-limit headers now come back on every response.',
|
|
74
|
+
kpis: [['96.8%', 'Login success', '+1.6 pts', 'metric:login-success'], ['72.3%', 'Signup completion', '+0.4 pts', 'metric:signup-completion'], ['412', 'Active integrations', '+4.1%', 'jira:IDPF-partner-filter']],
|
|
75
|
+
series: [['29 Jul', 95.8], ['12 Aug', 96.1], ['26 Aug', 96.5], ['9 Sep', 96.9], ['23 Sep', 95.2], ['7 Oct', 96.8]],
|
|
76
|
+
risks: [['Partner onboarding takes 9 days, target 5', 2]], needs: ['Sign-off on the sandbox key format, from Budi'],
|
|
77
|
+
rel: [['improvement', 'Webhook retries documented', 'Mon 28 Sep', 'jira:IDPF-29'], ['launch', 'Sandbox keys beta', 'Thu 1 Oct', 'jira:IDPF-31'], ['improvement', 'Rate-limit headers on every response', 'Tue 6 Oct', 'commit:9be210f']],
|
|
78
|
+
dec: [['Pick the sandbox key format', 'Ana Putri', 'Prefix keys with the partner id', 'Fri 9 Oct']],
|
|
79
|
+
inits: {
|
|
80
|
+
'IDPF-12': { title: 'Self-serve onboarding v2', stage: 'Build', status: 'on track', pct: 48, ms: 'Beta opens · 21 Oct 26', last: [I('Guided setup, step 1', 'Ana Putri', 1), I('Flow design signed off', 'Budi Santoso', 1), I('Copy first draft', 'Budi Santoso', 1)], next: [I('Beta opens, Wed 21 Oct', 'Ana Putri'), I('Steps 2 and 3', 'Ana Putri'), I('QA test plan', 'Sari Wijaya')], note: 'Beta on track for Wed 21 Oct.' },
|
|
81
|
+
'IDPF-07': { title: 'Partner API rate limits', stage: 'Spec', status: 'on track', pct: 15, ms: 'Options paper reviewed · 16 Oct 26', last: [I('Problem framed', 'Wawan', 1)], next: [I('Options paper reviewed, Fri 16 Oct', 'Wawan'), I('Partner traffic analysis', 'Chen Wei')], note: '' },
|
|
82
|
+
'IDPF-21': { title: 'Usage-based billing', stage: 'Intake', status: 'on track', pct: null, ms: 'Interviews booked · 16 Oct 26', last: [], next: [], note: '' },
|
|
83
|
+
'IDPF-31': { title: 'Sandbox keys for every partner', stage: 'Build', status: 'on track', pct: 90, ms: 'Release to all partners · 6 Oct 26' } } },
|
|
84
|
+
};
|
|
85
|
+
const TITLES = { title: 'Title', headline: 'Headline', release: 'Release notes', roadmap: 'Roadmap, 6 months', list: 'Initiative progress', decisions: 'Decisions needed', needs: 'Needs and risks', sources: 'Sources' };
|
|
86
|
+
const SECTION = { headline: 'Product metrics', release: 'What shipped', list: 'Coming next 2 weeks', decisions: 'Decisions needed', needs: 'Risk watch' };
|
|
87
|
+
const KIND = { title: 'title', headline: 'headline', release: 'release-notes', roadmap: 'roadmap', list: 'initiative-list', decisions: 'decision-table', needs: 'risks-and-asks', sources: 'source-list' };
|
|
88
|
+
const ORDER = {
|
|
89
|
+
1: ['title', 'headline', 'list', 'init', 'decisions', 'needs', 'release', 'sources'],
|
|
90
|
+
2: ['title', 'headline', 'roadmap', 'list', 'init', 'decisions', 'needs', 'release', 'sources'],
|
|
91
|
+
3: ['title', 'headline', 'release', 'roadmap', 'list', 'init', 'decisions', 'needs', 'sources'],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function chart(series) {
|
|
95
|
+
const Wd = 560; const Hd = 290; const x0 = 34; const x1 = Wd - 34; const vals = series.map((p) => p[1]); const lo = Math.min(...vals) - 0.6; const hi = Math.max(...vals) + 0.6;
|
|
96
|
+
const X = (i) => x0 + i * (x1 - x0) / (series.length - 1); const Y = (v) => 24 + (1 - (v - lo) / (hi - lo)) * (Hd - 70);
|
|
97
|
+
return '<svg viewBox="0 0 ' + Wd + ' ' + Hd + '" role="img" aria-label="Login success for the last 6 cycles"><line class="ax" x1="' + x0 + '" x2="' + x1 + '" y1="' + (Hd - 40) + '" y2="' + (Hd - 40) + '"/><polyline points="' + series.map((p, i) => X(i).toFixed(1) + ',' + Y(p[1]).toFixed(1)).join(' ') + '"/>'
|
|
98
|
+
+ series.map((p, i) => '<g data-value="Login success, ' + p[0] + ': ' + p[1] + '%' + (p[0] === '23 Sep' ? '. The dip was the login service outage.' : '') + '"><circle cx="' + X(i).toFixed(1) + '" cy="' + Y(p[1]).toFixed(1) + '" r="8"/><text x="' + X(i).toFixed(1) + '" y="' + (Hd - 14) + '">' + p[0] + '</text></g>').join('') + '</svg>';
|
|
99
|
+
}
|
|
100
|
+
const progSvg = (pct) => '<svg viewBox="0 0 600 18" preserveAspectRatio="none" aria-hidden="true"><rect class="trk" x="0" y="0" width="600" height="18"/><rect class="fil" x="0" y="0" width="' + Math.round(6 * pct) + '" height="18"/></svg>';
|
|
101
|
+
function roadmapSvg(c) {
|
|
102
|
+
const rows = [['Now', null], ['IDPF-12', 'Self-serve onboarding v2', [['spec', 0, 0.7], ['build', 0.7, c === CY.c ? 2.95 : 2.8], ['eval', 3, 3.65]]], ['IDPF-07', 'Partner API rate limits', [['spec', 1.9, 2.75], ['build', 2.75, 3.9]]], ['IDPF-31', 'Sandbox keys for every partner', [['build', 0.3, 2.15], ['hand', 2.15, 2.45]]],
|
|
103
|
+
['Next', null], ['IDPF-21', 'Usage-based billing', [['intake', 2.2, 3.5], ['spec', 3.5, 5]]], ['Later', null], ['IDPF-40', 'SSO for enterprise', [['intake', 4, 6]]]];
|
|
104
|
+
const MW = 140; const L = 300; let y = 40; let out = '<svg viewBox="0 0 1184 470" role="img" aria-label="Roadmap for six months">';
|
|
105
|
+
['Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan'].forEach((m, k) => { out += '<text class="m" x="' + (L + k * MW + MW / 2) + '" y="20">' + m + '</text><line class="grid" x1="' + (L + k * MW) + '" x2="' + (L + k * MW) + '" y1="28" y2="460"/>'; });
|
|
106
|
+
for (const r of rows) {
|
|
107
|
+
if (!r[1]) { out += '<text class="g" x="0" y="' + (y + 14) + '">' + r[0].toUpperCase() + '</text>'; y += 34; continue; }
|
|
108
|
+
out += '<g data-initiative="' + r[0] + '"><text x="0" y="' + (y + 16) + '">' + esc(r[1]) + '</text></g>';
|
|
109
|
+
for (const [k, a, b] of r[2]) out += '<rect class="b-' + k + '" x="' + (L + a * MW) + '" y="' + (y + 2) + '" width="' + ((b - a) * MW) + '" height="20" data-value="' + esc(r[1]) + ': ' + ({ spec: 'Spec', build: 'Build', eval: 'Evaluate', hand: 'Handoff', intake: 'Intake' })[k] + '"/>';
|
|
110
|
+
y += 44;
|
|
111
|
+
}
|
|
112
|
+
return out + '</svg>';
|
|
113
|
+
}
|
|
114
|
+
function riskSvg(c) {
|
|
115
|
+
const lab = ['', 'Low', 'Medium', 'High']; let y = 10; let out = '<svg viewBox="0 0 560 ' + (c.risks.length * 64 + 10) + '" role="img" aria-label="Risks by impact">';
|
|
116
|
+
for (const [t, v] of c.risks) { out += '<g data-value="' + esc(t) + ': ' + lab[v] + ' impact"><text x="0" y="' + (y + 16) + '">' + esc(t) + '</text><rect class="trk" x="0" y="' + (y + 26) + '" width="460" height="16"/><rect class="i' + v + '" x="0" y="' + (y + 26) + '" width="' + (v * 153) + '" height="16"/><text class="v" x="472" y="' + (y + 40) + '">' + lab[v] + '</text></g>'; y += 64; }
|
|
117
|
+
return out + '</svg>';
|
|
118
|
+
}
|
|
119
|
+
/* one slide; o = { v (design 1|2|3), n, N, c (facts), dense, from (the earlier facts, for data-from) } */
|
|
120
|
+
function slide(key, o) {
|
|
121
|
+
const c = o.c; const plain = o.v === 1; const ini = key.startsWith('init:') ? key.slice(5) : null;
|
|
122
|
+
const id = ini ? 'init-' + ini.toLowerCase() : key; const title = ini ? c.inits[ini].title : TITLES[key];
|
|
123
|
+
const kind = ini ? 'initiative' : KIND[key]; const sec = ini ? 'Coming next 2 weeks' : SECTION[key] || '';
|
|
124
|
+
const cls = 'pmos-slide k-' + (ini ? 'init' : key) + (plain ? ' plain' : '') + (o.dense ? ' dense' : '');
|
|
125
|
+
const open = '<section class="' + cls + '" data-slide-id="' + id + '" data-kind="' + kind + '" data-title="' + esc(title) + '"' + (sec ? ' data-section="' + esc(sec) + '"' : '') + '>';
|
|
126
|
+
const foot = '<footer class="s-ft"><span>' + pad2(o.n) + ' / ' + pad2(o.N) + '</span><span>' + esc(c.foot) + '</span></footer>';
|
|
127
|
+
if (key === 'title') return open + '<div class="t-in"><p class="eb">IDPF · Biweekly update</p><h1>MySPH & Identity Platform biweekly update</h1><p>Cycle ending ' + (c === CY.c ? 'Wed 21 Oct 2026' : 'Wed 7 Oct 2026') + '</p><p>Prepared by Wawan, IDPF</p><p class="eb">For the eShop & MySPH progress session</p></div>' + foot + '</section>';
|
|
128
|
+
const hd = '<header class="s-hd"><div class="s-num">' + pad2(o.n) + '</div><div class="s-band">' + esc(title) + '</div></header>';
|
|
129
|
+
let b = '';
|
|
130
|
+
const fromOf = (v, w) => (o.from && w != null && String(w) !== String(v) ? ' data-from="' + esc(w) + '"' : '');
|
|
131
|
+
if (key === 'headline') b = '<p class="hl">' + esc(c.head) + '</p><div class="hlg"><div class="kpis">' + c.kpis.map((k, i) => '<div class="kpi"><b data-src="' + k[3] + '" data-value="' + esc(k[1]) + ': ' + k[0] + (k[1] === 'Signup completion' ? '. This number has no source in the document yet.' : '. Read Mon 12 Oct 21:40.') + '"' + fromOf(k[0], o.from && o.from.kpis[i] ? o.from.kpis[i][0] : null) + '>' + k[0] + '</b><span>' + esc(k[1]) + '</span><span class="' + (/^-/.test(k[2]) ? 'dn' : 'up') + '">' + k[2] + '</span></div>').join('') + '</div><div class="chart"><p>Login success, last 6 cycles</p>' + chart(c.series) + '</div></div>';
|
|
132
|
+
else if (key === 'roadmap') b = '<div class="rm">' + roadmapSvg(c) + '</div>';
|
|
133
|
+
else if (key === 'list') b = '<div class="tbl"><div class="tr"><span>Initiative</span><span>Status</span><span>Next milestone</span><span>Live</span></div>' + ['IDPF-12', 'IDPF-07', 'IDPF-21', 'IDPF-31'].map((k) => { const x = c.inits[k]; return '<div class="tr"><span>' + esc(x.title) + '</span><span class="badge' + (x.status === 'at risk' ? ' risk' : '') + '" data-src="jira:' + k + '" data-value="' + esc(k + ' ' + x.status + ', set in Jira') + '"' + fromOf(x.status, o.from && o.from.inits[k] ? o.from.inits[k].status : null) + '>' + esc(x.stage + (x.status === 'at risk' ? ' · at risk' : x.status === 'shipped' ? ' · shipped' : '')) + '</span><span class="sm">' + esc(x.ms || 'Shipped') + '</span><span class="live" data-initiative="' + k + '">' + k + ' in PMOS</span></div>'; }).join('') + '</div>';
|
|
134
|
+
else if (ini) {
|
|
135
|
+
const x = c.inits[ini]; const px = o.from && o.from.inits[ini];
|
|
136
|
+
const col = (h, r, items) => '<div class="col" data-more="' + esc(h) + '"><h3>' + h + '<span>' + r + '</span></h3><ul>' + items.map((it, k) => '<li' + (k >= 4 ? ' class="pmos-more"' : '') + '>' + esc(it.t) + ' <span class="sm">· ' + esc(it.sub) + '</span></li>').join('') + '</ul>' + (items.length > 4 ? '<span class="more">+' + (items.length - 4) + ' more</span>' : '') + '</div>';
|
|
137
|
+
b = '<div class="top"><span class="badge' + (x.status === 'at risk' ? ' risk' : '') + '" data-src="jira:' + ini + '" data-value="' + esc(ini + ': ' + x.status) + '">' + esc(x.stage + (x.status === 'at risk' ? ' · at risk' : '')) + '</span><span class="live" data-initiative="' + ini + '">Live: ' + ini + ' in PMOS</span>'
|
|
138
|
+
+ (x.pct == null ? '<span class="sm">No breakdown</span>' : '<div class="prog">' + progSvg(x.pct) + '</div><span class="pct"' + fromOf(x.pct + '%', px && px.pct != null ? px.pct + '%' : null) + '>' + x.pct + '%</span>') + '</div>'
|
|
139
|
+
+ '<p class="sm">Next milestone: ' + esc(x.ms) + '</p><div class="cols">' + col('Last cycle', c.lastR, x.last || []) + col('Next cycle', c.nextR, x.next || []) + '</div>' + (x.note ? '<div class="note">' + esc(x.note) + '</div>' : '');
|
|
140
|
+
} else if (key === 'decisions') b = '<div class="tbl"><div class="tr dec"><span>Decision</span><span>Owner</span><span>Recommendation</span><span>Decide by</span></div>' + c.dec.map((d) => '<div class="tr dec"><span>' + esc(d[0]) + '</span><span>' + esc(d[1]) + '</span><span>' + esc(d[2]) + '</span><span>' + esc(d[3]) + '</span></div>').join('') + '</div><div class="note info">Decisions made in the session go to the decision log after the session.</div>';
|
|
141
|
+
else if (key === 'needs') b = '<div class="cols">' + (o.v >= 3 ? '<div class="col"><h3>Risk watch, by impact<span>' + c.risks.length + ' risks</span></h3><div class="risk">' + riskSvg(c) + '</div></div>' : '<div class="col"><h3>Risk watch<span>' + c.risks.length + ' risks</span></h3><ul>' + c.risks.map((r) => '<li>' + esc(r[0]) + '</li>').join('') + '</ul></div>')
|
|
142
|
+
+ '<div class="col"><h3>Needs from the session<span>' + c.needs.length + ' asks</span></h3><ul>' + c.needs.map((t) => '<li>' + esc(t) + '</li>').join('') + '</ul></div></div>';
|
|
143
|
+
else if (key === 'release') b = '<ol class="tl">' + c.rel.map((r) => '<li><span class="pill ' + r[0] + '" data-src="' + r[3] + '">' + r[0] + '</span><span><b>' + esc(r[1]) + '</b> <span class="sm">· ' + r[2] + '</span></span></li>').join('') + '</ol>';
|
|
144
|
+
else b = '<div class="cols"><div class="col"><h3>What this deck read<span>5 sources</span></h3><ul><li>Jira IDPF, 14 tickets</li><li>Initiatives and releases in PMOS</li><li>Product metrics</li><li>Last cycle\'s update</li><li>Your notes</li></ul></div><div class="col"><h3>How to read it<span>Same every cycle</span></h3><ul><li>Dates are written in full</li><li>Every number has a source: click it</li><li>Decisions and Risk watch always show</li></ul></div></div>';
|
|
145
|
+
return open + hd + '<div class="s-body">' + b + '</div>' + foot + '</section>';
|
|
146
|
+
}
|
|
147
|
+
/* a deck: the design's order with a cycle's facts; extra = initiative ids that get a slide beyond the design's rule (Spec or later) */
|
|
148
|
+
function deckKeys(v, c, extra) {
|
|
149
|
+
const inits = ['IDPF-12', 'IDPF-07', ...(extra || [])];
|
|
150
|
+
return ORDER[Math.min(3, v)].flatMap((k) => (k === 'init' ? inits.map((x) => 'init:' + x) : [k]));
|
|
151
|
+
}
|
|
152
|
+
function deckHtml(v, c, o = {}) {
|
|
153
|
+
const keys = deckKeys(v, c, o.extra);
|
|
154
|
+
return '<!doctype html><html><head><style>' + DECK_CSS + '</style></head><body>' + keys.map((k, i) => slide(k, { v, n: i + 1, N: keys.length, c, dense: o.dense, from: o.from })).join('') + '</body></html>';
|
|
155
|
+
}
|
|
156
|
+
const outlineOfKeys = (keys, c) => keys.map((k) => (k.startsWith('init:') ? { id: 'init-' + k.slice(5).toLowerCase(), kind: 'initiative', title: c.inits[k.slice(5)].title } : { id: k, kind: KIND[k], title: TITLES[k] }));
|
|
157
|
+
|
|
158
|
+
/* ── the document's look (the Monthly OOH x Digital review) ── */
|
|
159
|
+
const DOC_CSS = [
|
|
160
|
+
'.pmos-doc-in{max-width:820px;margin:0 auto;padding:24px 28px;font:400 15px/1.55 var(--sans);color:var(--slide-fg)}',
|
|
161
|
+
'header.d-h{display:grid;gap:6px;padding-bottom:12px;border-bottom:2px solid var(--slide-brand)}.plain header.d-h{border-bottom-color:var(--slide-border)}',
|
|
162
|
+
'header.d-h p{margin:0;font:400 12px var(--mono);letter-spacing:.06em;color:var(--slide-muted)}header.d-h h2{margin:0;font:700 26px/1.2 var(--sans)}',
|
|
163
|
+
'.pmos-sec{padding:12px 0;border-bottom:1px solid var(--slide-rule)}.pmos-sec h3{margin:0 0 6px;font:700 17px var(--sans);color:var(--slide-brand)}.plain .pmos-sec h3{color:var(--slide-fg)}',
|
|
164
|
+
'.pmos-sec p,.pmos-sec ul{margin:6px 0 0}.kp{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:10px}.kp div{display:grid;gap:4px;padding:12px;border:1px solid var(--slide-border)}',
|
|
165
|
+
'.kp b{font:700 26px/1.1 var(--sans);color:var(--slide-primary)}.kp span{font:400 13px var(--sans);color:var(--slide-muted)}.src{font:400 12px var(--mono);color:var(--slide-primary)}',
|
|
166
|
+
'.bars svg{display:block;width:100%;max-width:480px;height:auto}.bars rect.on{fill:var(--slide-primary)}.bars rect.off{fill:var(--slide-border)}.bars text{font:12px var(--sans);fill:var(--slide-muted);text-anchor:middle}',
|
|
167
|
+
'details.dec{border:1px solid var(--slide-border);border-left:4px solid var(--slide-note-warning);padding:8px 12px;margin-top:8px}details.dec summary{cursor:pointer;font-weight:600}',
|
|
168
|
+
].join('\n');
|
|
169
|
+
const CITIES = [['Jakarta', 2140, 1810, true], ['Surabaya', 1402, 1236, true], ['Bandung', 1270, 1170, true], ['Medan', 640, 655, false], ['Makassar', 512, 520, false]];
|
|
170
|
+
function docHtml(v, o = {}) {
|
|
171
|
+
const plain = v === 1; const grey = !!o.grey; const max = 2140;
|
|
172
|
+
const C = CITIES.filter((c) => c[3] || grey);
|
|
173
|
+
const bars = '<svg viewBox="0 0 ' + (C.length * 104 + 20) + ' 200" role="img" aria-label="Sign-ups by city">' + C.map(([n, a, b, p], i) => '<g data-value="' + n + ': ' + a.toLocaleString('en-US') + ' sign-ups, was ' + b.toLocaleString('en-US') + '"><rect class="' + (p ? 'on' : 'off') + '" x="' + (20 + i * 104) + '" y="' + Math.round(170 - a / max * 140) + '" width="64" height="' + Math.round(a / max * 140) + '"/><text x="' + (52 + i * 104) + '" y="192">' + n + '</text></g>').join('') + '</svg>';
|
|
174
|
+
const sec = (id, kind, title, inner) => '<section class="pmos-sec" data-sec-id="' + id + '" data-kind="' + kind + '" data-title="' + esc(title) + '" data-section="' + esc(title) + '"><h3>' + esc(title) + '</h3>' + inner + '</section>';
|
|
175
|
+
const decisions = sec('decisions', 'decisions', 'Decisions needed', '<details class="dec" data-more="decision-1"><summary>Repeat the outdoor run in November, or move the budget to search</summary><p>Poster cities grew 14% on the September run; cities without posters stayed flat. <span class="src" data-src="sheet:spend-oct">Spend sheet, Oct</span></p></details><details class="dec" data-more="decision-2"><summary>One name for the campaign link, so both teams count the same sign-ups</summary><p>OOH counts the link ooh-oct; Digital counts every sign-up in the city. <span class="src" data-src="dashboard:signup-funnel">Signup funnel dashboard</span></p></details>');
|
|
176
|
+
const summary = sec('summary', 'summary', 'Summary', '<p>The October outdoor run for MySPH sign-up ended Sun 11 Oct. Digital sign-ups rose in the three cities that had posters.</p>' + (plain ? '' : '<div class="kp"><div><b data-from="4,216" data-src="dashboard:signup-funnel">4,812</b><span>Sign-ups in poster cities</span></div><div><b data-src="sheet:spend-oct">92%</b><span>Outdoor spend of plan</span></div><div><b data-from="+6%" data-src="report:reach-oct">+14%</b><span>Digital reach, poster cities</span></div></div>'));
|
|
177
|
+
const chartS = sec('chart', 'chart', 'Sign-ups by city', plain ? '<ul>' + C.map((c) => '<li>' + c[0] + ': ' + c[1].toLocaleString('en-US') + '</li>').join('') + '</ul>' : '<div class="bars">' + bars + '</div><p class="src" data-src="dashboard:signup-funnel">Signup funnel dashboard, link ooh-oct</p>');
|
|
178
|
+
const spend = sec('spend', 'list', 'Spend and reach', '<ul><li>Outdoor spend: 92% of plan</li><li>Posters ran Mon 28 Sep to Sun 11 Oct in 3 cities</li><li>Digital reach in those cities: up 14% on the previous run</li></ul>');
|
|
179
|
+
const acts = sec('actions', 'actions', 'Actions from last review', '<div data-more="actions"><p>1 of 2 done. <span class="src">Show both</span></p><ul class="pmos-more"><li>Share the poster map with Digital · Gita Rahma · done Wed 7 Oct</li><li>Agree one sign-up definition · Lina Kusuma · open</li></ul></div>');
|
|
180
|
+
const body = plain ? summary + chartS + spend + decisions + acts : decisions + summary + chartS + spend + acts;
|
|
181
|
+
return '<!doctype html><html><head><style>' + DOC_CSS + '</style></head><body><article class="pmos-doc-in' + (plain ? ' plain' : '') + '"><header class="d-h"><p>GROWTH · MONTHLY REVIEW · OCT 2026</p><h2>Monthly OOH x Digital review</h2></header>' + body + '</article></body></html>';
|
|
182
|
+
}
|
|
183
|
+
const DOC_OUT = (v) => (v === 1 ? [['summary', 'summary', 'Summary'], ['chart', 'chart', 'Sign-ups by city'], ['spend', 'list', 'Spend and reach'], ['decisions', 'decisions', 'Decisions needed'], ['actions', 'actions', 'Actions from last review']]
|
|
184
|
+
: [['decisions', 'decisions', 'Decisions needed'], ['summary', 'summary', 'Summary'], ['chart', 'chart', 'Sign-ups by city'], ['spend', 'list', 'Spend and reach'], ['actions', 'actions', 'Actions from last review']]).map(([id, kind, title]) => ({ id, kind, title }));
|
|
185
|
+
|
|
186
|
+
/* ── any other report: a plain deck or document from the sections of its draft ── */
|
|
187
|
+
function genericHtml(fmt, name, secs, step) {
|
|
188
|
+
const dense = step > 1;
|
|
189
|
+
if (fmt === 'doc') return '<!doctype html><html><head><style>' + DOC_CSS + '</style></head><body><article class="pmos-doc-in' + (step === 1 ? ' plain' : '') + '"><header class="d-h"><h2>' + esc(name) + '</h2></header>' + secs.map((s, i) => '<section class="pmos-sec" data-sec-id="sec-' + (i + 1) + '" data-kind="text" data-title="' + esc(s.h) + '" data-section="' + esc(s.h) + '"><h3>' + esc(s.h) + '</h3><ul>' + s.lines.map((l) => '<li>' + esc(l) + '</li>').join('') + '</ul></section>').join('') + '</article></body></html>';
|
|
190
|
+
const N = secs.length + 1;
|
|
191
|
+
return '<!doctype html><html><head><style>' + DECK_CSS + '</style></head><body><section class="pmos-slide k-title' + (step === 1 ? ' plain' : '') + '" data-slide-id="title" data-kind="title" data-title="Title"><div class="t-in"><h1>' + esc(name) + '</h1></div><footer class="s-ft"><span>01 / ' + pad2(N) + '</span><span>' + esc(name) + '</span></footer></section>'
|
|
192
|
+
+ secs.map((s, i) => '<section class="pmos-slide' + (step === 1 ? ' plain' : '') + (dense ? ' dense' : '') + '" data-slide-id="sec-' + (i + 1) + '" data-kind="text" data-title="' + esc(s.h) + '" data-section="' + esc(s.h) + '"><header class="s-hd"><div class="s-num">' + pad2(i + 2) + '</div><div class="s-band">' + esc(s.h) + '</div></header><div class="s-body"><ul>' + s.lines.map((l) => '<li>' + esc(l) + '</li>').join('') + '</ul></div><footer class="s-ft"><span>' + pad2(i + 2) + ' / ' + pad2(N) + '</span><span>' + esc(name) + '</span></footer></section>').join('') + '</body></html>';
|
|
193
|
+
}
|
|
194
|
+
const genericOutline = (fmt, secs) => (fmt === 'doc' ? [] : [{ id: 'title', kind: 'title', title: 'Title' }]).concat(secs.map((s, i) => ({ id: 'sec-' + (i + 1), kind: 'text', title: s.h })));
|
|
195
|
+
const sectionsOf = (md) => String(md || '').split(/\n(?=## )/).filter((b) => b.startsWith('## ')).map((b) => { const [h, ...rest] = b.split('\n'); return { h: h.replace(/^##\s+/, ''), lines: rest.map((l) => l.replace(/^[-*]\s+/, '').replace(/\*\*/g, '').trim()).filter(Boolean).slice(0, 6) }; });
|
|
196
|
+
|
|
197
|
+
/* ── the state ── */
|
|
198
|
+
const THUMB = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 90"><rect width="160" height="90" fill="white"/><rect x="8" y="8" width="144" height="14" fill-opacity=".8"/><rect x="8" y="30" width="90" height="6" fill-opacity=".18"/><rect x="8" y="42" width="120" height="6" fill-opacity=".18"/><rect x="8" y="54" width="70" height="6" fill-opacity=".18"/></svg>');
|
|
199
|
+
const DESIGNS = {}; // setup id -> design state
|
|
200
|
+
const CYCLES = {}; // doc path -> { v, extra, differences, decisions, rendering, t0 }
|
|
201
|
+
const NOTES = {}; // doc path -> { speaker, pins }
|
|
202
|
+
let pinSeq = 20;
|
|
203
|
+
let ctx = null; // { setups(), docs(), docText(path), logEvent(path, ev) }
|
|
204
|
+
|
|
205
|
+
const V = (n, made_at, turn, outline, extra) => ({ n, format: extra && extra.format || 'deck', made_at, turn, outline, ...(extra || {}) });
|
|
206
|
+
function seedRs1() {
|
|
207
|
+
const c = CY.c; const o1 = outlineOfKeys(deckKeys(1, c), c); const o2 = outlineOfKeys(deckKeys(2, c), c); const o3 = outlineOfKeys(deckKeys(3, c), c);
|
|
208
|
+
return { rid: 'rs1', state: 'saved', format: 'deck', description: 'This goes to the eShop & MySPH progress session every other Thursday: 28 people, mostly engineering leads and Dewi (CEO). They want what shipped, what slips and what needs a decision. The decisions must stand out.',
|
|
209
|
+
samples: [{ id: 's1', name: 'Biweekly update, 26 Mar.pptx', kind: 'pptx', source: 'upload', took: 'Numbered blue header band, one slide per initiative, Last cycle and Next cycle columns', thumb: THUMB, readable: true },
|
|
210
|
+
{ id: 's2', name: 'Confluence: IDPF roadmap', kind: 'link', source: 'acme.atlassian.net/wiki/spaces/IDPF/pages/roadmap', took: 'The roadmap as six months of bars, grouped Now, Next and Later', thumb: null, readable: true }],
|
|
211
|
+
log: [{ at: '2026-10-12T22:00:00.000Z', who: 'you', kind: 'describe', text: 'This goes to the eShop & MySPH progress session every other Thursday: 28 people, mostly engineering leads and Dewi (CEO). They want what shipped, what slips and what needs a decision. The decisions must stand out.' },
|
|
212
|
+
{ at: '2026-10-12T22:02:00.000Z', who: 'pmos', kind: 'version', version: 1, text: 'D1 is a deck of 10 slides from this cycle\'s data: headline numbers, the initiative list, one slide per initiative, decisions, risks and release notes. Add a past deck if you want it to look like what the session knows.' },
|
|
213
|
+
{ at: '2026-10-12T22:05:00.000Z', who: 'you', kind: 'sample', sample: 's1', text: 'Biweekly update, 26 Mar.pptx' },
|
|
214
|
+
{ at: '2026-10-12T22:06:00.000Z', who: 'you', kind: 'sample', sample: 's2', text: 'Confluence: IDPF roadmap' },
|
|
215
|
+
{ at: '2026-10-12T22:07:00.000Z', who: 'you', kind: 'turn', text: 'Here is my 26 Mar deck, the one people liked, and our roadmap page. Make it look like them.' },
|
|
216
|
+
{ at: '2026-10-12T22:09:00.000Z', who: 'pmos', kind: 'version', version: 2, text: 'D2 follows your 26 Mar deck: the numbered blue header band, one slide per initiative with Last cycle and Next cycle columns. The roadmap comes from your Confluence page: six months of bars, Now, Next and Later.' },
|
|
217
|
+
{ at: '2026-10-12T22:12:00.000Z', who: 'you', kind: 'turn', text: 'Make the risks a chart, and move release notes up, right after the headline.', points: [{ n: 1, slide: 'needs', x: 25, y: 40, text: 'Make the risks a chart' }] },
|
|
218
|
+
{ at: '2026-10-12T22:16:00.000Z', who: 'pmos', kind: 'version', version: 3, text: 'D3: Risk watch is a bar chart by impact, and Release notes are slide 3. The data did not change.' },
|
|
219
|
+
{ at: '2026-10-12T22:20:00.000Z', who: 'you', kind: 'accept', version: 3, text: 'Saved D3 as the design.' }],
|
|
220
|
+
versions: [V(1, '2026-10-12T22:02:00.000Z', 'From your description', o1, { v: 1 }), V(2, '2026-10-12T22:09:00.000Z', 'After your samples: the 26 Mar deck and the roadmap page', o2, { v: 2 }), V(3, '2026-10-12T22:16:00.000Z', 'Make the risks a chart, and move release notes up, right after the headline.', o3, { v: 3 })],
|
|
221
|
+
current: 3, saved: { version: 3, at: '2026-10-12T22:20:00.000Z' },
|
|
222
|
+
rules: { same: ['The slide order: title, headline, release notes, roadmap, initiative list, one slide per initiative, decisions, needs and risks, sources', 'The numbered blue header band and the footer', 'One slide per initiative in Spec or later, with Last cycle and Next cycle columns', 'Decisions stand out: their own slide, with owner and date'],
|
|
223
|
+
changes: ['The data: numbers, statuses, dates and releases', 'Your words in the document and your notes', 'How many initiative slides there are'], learned: ['Risks as a bar chart by impact, not a list', 'Release notes right after the headline'] },
|
|
224
|
+
running: null };
|
|
225
|
+
}
|
|
226
|
+
function seedRs3() {
|
|
227
|
+
return { rid: 'rs3', state: 'draft', format: 'doc', description: 'A monthly page for the OOH and Digital teams. One view of the month: spend, reach, sign-ups by city, and what we decide next. Gita and Lina read it before the review.',
|
|
228
|
+
samples: [{ id: 's3', name: 'Confluence: OOH x Digital running page', kind: 'link', source: 'acme.atlassian.net/wiki/spaces/GROWTH/pages/ooh-digital', took: 'One page per month, decisions at the top, both teams\' numbers side by side', thumb: null, readable: true },
|
|
229
|
+
{ id: 's4', name: 'TSC Digital input, Sep 2026', kind: 'pmos-doc', source: 'planning/reports/rs2/2026-09-01.md', took: 'Numbers with their definition and source, a short Highlights and lowlights list', thumb: null, readable: true }],
|
|
230
|
+
log: [{ at: '2026-10-12T21:38:00.000Z', who: 'you', kind: 'describe', text: 'A monthly page for the OOH and Digital teams. One view of the month: spend, reach, sign-ups by city, and what we decide next. Gita and Lina read it before the review.' },
|
|
231
|
+
{ at: '2026-10-12T21:40:00.000Z', who: 'pmos', kind: 'version', version: 1, text: 'D1 is a document with five sections from this month\'s data. Add the page you use today if it should feel familiar.' },
|
|
232
|
+
{ at: '2026-10-12T21:44:00.000Z', who: 'you', kind: 'sample', sample: 's3', text: 'Confluence: OOH x Digital running page' },
|
|
233
|
+
{ at: '2026-10-12T21:44:30.000Z', who: 'you', kind: 'sample', sample: 's4', text: 'TSC Digital input, Sep 2026' },
|
|
234
|
+
{ at: '2026-10-12T21:45:00.000Z', who: 'you', kind: 'turn', text: 'This is the running page, and my TSC input has the numbers style I like.' },
|
|
235
|
+
{ at: '2026-10-12T21:47:00.000Z', who: 'pmos', kind: 'version', version: 2, text: 'D2 puts decisions first, as on the running page, and shows sign-ups by city as a chart you can hover. Each number links to its source, as in your TSC input.' }],
|
|
236
|
+
versions: [V(1, '2026-10-12T21:40:00.000Z', 'From your description', DOC_OUT(1), { format: 'doc', v: 1 }), V(2, '2026-10-12T21:47:00.000Z', 'This is the running page, and my TSC input has the numbers style I like.', DOC_OUT(2), { format: 'doc', v: 2 })],
|
|
237
|
+
current: 2, saved: null, rules: null, running: null };
|
|
238
|
+
}
|
|
239
|
+
function design(id) {
|
|
240
|
+
if (DESIGNS[id]) return DESIGNS[id];
|
|
241
|
+
const s = ctx.setups().find((x) => x.id === id); if (!s) return null;
|
|
242
|
+
DESIGNS[id] = id === 'rs1' ? seedRs1() : id === 'rs3' ? seedRs3() : { rid: id, state: 'none', format: s.output === 'google-slides' ? 'deck' : 'doc', description: null, samples: [], log: [], versions: [], current: null, saved: null, rules: null, running: null };
|
|
243
|
+
return DESIGNS[id];
|
|
244
|
+
}
|
|
245
|
+
const curDoc = (id) => ctx.docs().filter((d) => d.setup === id).sort((a, b) => String(b.slot).localeCompare(String(a.slot))).find((d) => d.state === 'draft') || ctx.docs().filter((d) => d.setup === id)[0];
|
|
246
|
+
function versionHtml(id, D, v) {
|
|
247
|
+
if (D.rid === 'rs1' && v.v) return deckHtml(v.v, CY.c, { dense: v.dense, extra: v.extra });
|
|
248
|
+
if (D.rid === 'rs3' && v.v && v.format === 'doc') return docHtml(v.v, { grey: v.grey });
|
|
249
|
+
const s = ctx.setups().find((x) => x.id === id) || { name: id }; const d = curDoc(id);
|
|
250
|
+
return genericHtml(v.format, s.name, sectionsOf(d ? ctx.docText(d.path) : ''), v.step || 1);
|
|
251
|
+
}
|
|
252
|
+
/* a job lands when its 1.5 seconds are up (read on each GET) */
|
|
253
|
+
function settle(id) {
|
|
254
|
+
const D = DESIGNS[id]; if (!D || !D.running || performance.now() - D.running.t0 < 1500) return;
|
|
255
|
+
const t = D.running.turn; const pts = D.running.points || []; D.running = null;
|
|
256
|
+
if (/\bfail\b/i.test(t)) { D.log.push({ at: nowIso(), who: 'pmos', kind: 'error', text: 'The job ended before it made a version: the render did not pass the check (a slide had no data-slide-id). Nothing changed. Try the turn again.' }); return; }
|
|
257
|
+
const base = D.versions.find((x) => x.n === D.current) || null; const n = (D.versions.length ? Math.max(...D.versions.map((x) => x.n)) : 0) + 1;
|
|
258
|
+
const dense = /dense|tight|compact/i.test(t) || !!(base && base.dense); const grey = /grey|gray|without posters/i.test(t) || !!(base && base.grey);
|
|
259
|
+
let v;
|
|
260
|
+
if (D.rid === 'rs1' && D.format === 'deck') { const lv = base ? base.v : 1; v = V(n, nowIso(), t, outlineOfKeys(deckKeys(lv, CY.c), CY.c), { v: lv, dense }); }
|
|
261
|
+
else if (D.rid === 'rs3' && D.format === 'doc') { const lv = base ? base.v : 1; v = V(n, nowIso(), t, DOC_OUT(lv), { format: 'doc', v: lv, grey }); }
|
|
262
|
+
else { const d = curDoc(id); const secs = sectionsOf(d ? ctx.docText(d.path) : ''); v = V(n, nowIso(), t, genericOutline(D.format, secs), { format: D.format, step: (base && base.format === D.format ? (base.step || 1) + 1 : 1) }); }
|
|
263
|
+
D.versions.push(v); D.current = n;
|
|
264
|
+
const did = pts.length ? pts.map((p) => '"' + p.text + '"').join(' and ') + ' done. ' : '';
|
|
265
|
+
D.log.push({ at: nowIso(), who: 'pmos', kind: 'version', version: n, text: 'D' + n + ': ' + did + (n === 1 ? 'A ' + (D.format === 'deck' ? 'deck' : 'document') + ' from your description and this cycle\'s data.' : dense && !(base && base.dense) ? 'Tighter spacing on every slide. ' : grey && !(base && base.grey) ? 'The cities without posters show in grey. ' : '') + ' The data did not change. Compare it with D' + (base ? base.n : n) + ' if you like.' });
|
|
266
|
+
}
|
|
267
|
+
function designView(id) {
|
|
268
|
+
settle(id); const D = design(id); const s = ctx.setups().find((x) => x.id === id);
|
|
269
|
+
return { ok: true, setup: { id: s.id, name: s.name, source: s.source, output: s.output }, design: { state: D.state, format: D.format, description: D.description, samples: clone(D.samples), log: clone(D.log),
|
|
270
|
+
versions: D.versions.map(({ n, format, made_at, turn, outline }) => ({ n, format, made_at, turn, outline: clone(outline) })), current: D.current, saved: D.saved ? { ...D.saved } : null, rules: D.rules ? clone(D.rules) : null,
|
|
271
|
+
running: D.running ? { since: D.running.since, turn: D.running.turn } : null } };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ── cycles ── */
|
|
275
|
+
function cycleOf(path) {
|
|
276
|
+
const d = ctx.docs().find((x) => x.path === path); if (!d) return null;
|
|
277
|
+
if (CYCLES[path]) return CYCLES[path];
|
|
278
|
+
const D = d.setup ? DESIGNS[d.setup] || (d.setup === 'rs1' || d.setup === 'rs3' ? design(d.setup) : null) : null;
|
|
279
|
+
let rec = { designed: false };
|
|
280
|
+
if (D && D.saved) {
|
|
281
|
+
if (D.rid === 'rs1' && d.slot === '2026-10-21') rec = { designed: true, version: D.saved.version, v: 3, c: 'c', extra: ['IDPF-21'], decisions: [], rendering: null };
|
|
282
|
+
else if (D.rid === 'rs1' && d.slot === '2026-10-07') rec = { designed: true, version: D.saved.version, v: 3, c: 'p', extra: [], decisions: [], rendering: null };
|
|
283
|
+
else if (D.rid !== 'rs1' && d.state === 'draft') rec = { designed: true, version: D.saved.version, decisions: [], rendering: null };
|
|
284
|
+
}
|
|
285
|
+
CYCLES[path] = rec; return rec;
|
|
286
|
+
}
|
|
287
|
+
function cycleRender(path) {
|
|
288
|
+
const d = ctx.docs().find((x) => x.path === path); const C = cycleOf(path); const D = DESIGNS[d.setup];
|
|
289
|
+
if (D.rid === 'rs1') return deckHtml(C.v, CY[C.c], { extra: C.extra, from: C.c === 'c' ? CY.p : null });
|
|
290
|
+
return versionHtml(d.setup, D, D.versions.find((x) => x.n === C.version));
|
|
291
|
+
}
|
|
292
|
+
function cycleView(path) {
|
|
293
|
+
const d = ctx.docs().find((x) => x.path === path); if (!d) return json({ ok: false, error: 'no such report document' }, 404);
|
|
294
|
+
const C = cycleOf(path);
|
|
295
|
+
if (C.rendering && performance.now() - C.rendering >= 1500) { C.rendering = null; C.extra = []; }
|
|
296
|
+
const D = d.setup ? DESIGNS[d.setup] : null;
|
|
297
|
+
const prevDoc = ctx.docs().filter((x) => x.setup === d.setup && String(x.slot) < String(d.slot)).sort((a, b) => String(b.slot).localeCompare(String(a.slot)))[0] || null;
|
|
298
|
+
if (!C.designed) return json({ ok: true, designed: false, design_version: null, format: D ? D.format : null, outline: [], differences: [], decisions: [], matches: false, rendering: false, previous: prevDoc ? { path: prevDoc.path, slot: prevDoc.slot } : null });
|
|
299
|
+
let outline; let differences = [];
|
|
300
|
+
if (D.rid === 'rs1') {
|
|
301
|
+
outline = outlineOfKeys(deckKeys(C.v, CY[C.c], C.extra), CY[C.c]);
|
|
302
|
+
const ref = (D.versions.find((x) => x.n === C.version) || {}).outline || [];
|
|
303
|
+
differences = outline.filter((s) => !ref.some((r) => r.id === s.id)).map((s) => ({ id: 'added:' + s.id, what: 'This draft has one slide more than the design. Usage-based billing moved into Intake this cycle, so PMOS gave it its own slide (slide ' + (outline.findIndex((x) => x.id === s.id) + 1) + '). The design shows a slide for each initiative in Spec or later.', slide: s.id }));
|
|
304
|
+
} else outline = clone((D.versions.find((x) => x.n === C.version) || {}).outline || []);
|
|
305
|
+
return json({ ok: true, designed: true, design_version: C.version, format: D.format, outline, differences, decisions: clone(C.decisions), matches: !differences.length, rendering: !!C.rendering, previous: prevDoc ? { path: prevDoc.path, slot: prevDoc.slot } : null });
|
|
306
|
+
}
|
|
307
|
+
function decide(b) {
|
|
308
|
+
const d = ctx.docs().find((x) => x.path === b.path); if (!d) return json({ ok: false, error: 'no such report document' }, 404);
|
|
309
|
+
const C = cycleOf(b.path); if (!C.designed) return json({ ok: false, error: 'this cycle has no design' }, 400);
|
|
310
|
+
if (!['keep', 'adopt', 'match'].includes(b.choice)) return json({ ok: false, error: 'choice is keep, adopt or match' }, 400);
|
|
311
|
+
const D = DESIGNS[d.setup]; const at = nowIso();
|
|
312
|
+
C.decisions.push({ diff: b.diff, choice: b.choice, at });
|
|
313
|
+
if (b.choice === 'adopt') {
|
|
314
|
+
const n = Math.max(...D.versions.map((x) => x.n)) + 1; const keys = deckKeys(3, CY.c, ['IDPF-21']);
|
|
315
|
+
D.versions.push(V(n, at, 'Made part of the design from the Wed 21 Oct draft: a new initiative in Intake gets its own slide too', outlineOfKeys(keys, CY.c), { v: 3, extra: ['IDPF-21'] }));
|
|
316
|
+
D.current = n; D.saved = { version: n, at }; C.version = n; C.decisions = [];
|
|
317
|
+
D.rules.learned = [...D.rules.learned, 'A new initiative in Intake gets its own slide too'];
|
|
318
|
+
D.log.push({ at, who: 'pmos', kind: 'version', version: n, text: 'D' + n + ': the Wed 21 Oct draft is now part of the design. A new initiative in Intake gets its own slide too.' });
|
|
319
|
+
}
|
|
320
|
+
if (b.choice === 'match') { C.rendering = performance.now(); C.decisions = C.decisions.filter((x) => x.choice !== 'match'); }
|
|
321
|
+
ctx.logEvent(b.path, { kind: 'design', at, by: 'pm', choice: b.choice, diff: b.diff, what: b.choice === 'keep' ? 'Kept the extra slide for Usage-based billing for this cycle.' : b.choice === 'adopt' ? 'Made the extra slide part of the design.' : 'Asked PMOS to draw the cycle again to match the design.' });
|
|
322
|
+
return json({ ok: true });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ── notes ── */
|
|
326
|
+
const P = (id, slide, x, y, text, by, state, at, section) => ({ id, slide, x, y, text, by, state, at, ...(section ? { section } : {}) });
|
|
327
|
+
function notesOf(path) {
|
|
328
|
+
if (NOTES[path]) return NOTES[path];
|
|
329
|
+
const d = ctx.docs().find((x) => x.path === path);
|
|
330
|
+
let N = { speaker: {}, pins: [] };
|
|
331
|
+
if (d && d.setup === 'rs1' && d.slot === '2026-10-21') N = { speaker: { title: { text: 'Welcome. Fifteen minutes, then questions. Lina joins for the beta list.', at: '2026-10-12T21:50:00.000Z' }, headline: { text: 'Lead with sandbox keys. Then say the beta date moved, and why, in one sentence.', at: '2026-10-12T21:51:00.000Z' } },
|
|
332
|
+
pins: [P('n1', 'headline', 80, 47, 'The dip on 23 Sep was the outage on the login service, not a trend.', 'you', 'kept', '2026-10-12T21:52:00.000Z'), P('n2', 'headline', 17, 42, 'Signup completion has no source on this slide. Add the signup funnel dashboard as its source.', 'pmos', 'open', '2026-10-12T21:55:00.000Z', 'Product metrics'),
|
|
333
|
+
P('n3', 'init-idpf-12', 84, 13, 'QA capacity is back from Mon 19 Oct. Ask Ana to confirm the new beta date.', 'you', 'kept', '2026-10-12T21:58:00.000Z'), P('n4', 'decisions', 52, 30, 'Ana owns this call. Give her the floor for this one.', 'you', 'kept', '2026-10-12T22:01:00.000Z', 'Decisions needed')] };
|
|
334
|
+
if (d && d.setup === 'rs1' && d.slot === '2026-10-07') N = { speaker: { title: { text: 'Welcome. Fifteen minutes, then questions.', at: '2026-10-07T14:00:00.000Z' }, headline: { text: 'Say the 23 Sep dip was the outage, not a trend.', at: '2026-10-07T14:01:00.000Z' },
|
|
335
|
+
'init-idpf-12': { text: 'Start with the beta date, then the scope. Ana answers questions on QA.', at: '2026-10-07T14:02:00.000Z' }, decisions: { text: 'Ask Budi for the key format today.', at: '2026-10-07T14:03:00.000Z' }, release: { text: 'Thank the platform team for the headers work.', at: '2026-10-07T14:04:00.000Z' } },
|
|
336
|
+
pins: [P('q1', 'headline', 73, 52, 'The dip on 23 Sep was the outage on the login service.', 'you', 'kept', '2026-10-07T15:30:00.000Z')] };
|
|
337
|
+
NOTES[path] = N; return N;
|
|
338
|
+
}
|
|
339
|
+
function notesView(path) {
|
|
340
|
+
const d = ctx.docs().find((x) => x.path === path); if (!d) return json({ ok: false, error: 'no such report document' }, 404);
|
|
341
|
+
const N = notesOf(path); const sug = {};
|
|
342
|
+
const prev = ctx.docs().filter((x) => x.setup === d.setup && String(x.slot) < String(d.slot)).sort((a, b) => String(b.slot).localeCompare(String(a.slot)))[0];
|
|
343
|
+
if (prev) { const PN = notesOf(prev.path); for (const [k, v] of Object.entries(PN.speaker)) if (v.text && !N.speaker[k]) sug[k] = { text: v.text, from_slot: prev.slot }; }
|
|
344
|
+
return json({ ok: true, speaker: clone(N.speaker), suggestions: sug, pins: clone(N.pins) });
|
|
345
|
+
}
|
|
346
|
+
function speaker(b) {
|
|
347
|
+
const d = ctx.docs().find((x) => x.path === b.path); if (!d) return json({ ok: false, error: 'no such report document' }, 404);
|
|
348
|
+
if (!b.slide) return json({ ok: false, error: 'slide is required' }, 400);
|
|
349
|
+
const N = notesOf(b.path);
|
|
350
|
+
if (b.keep) { const prev = ctx.docs().filter((x) => x.setup === d.setup && String(x.slot) < String(d.slot)).sort((a, c) => String(c.slot).localeCompare(String(a.slot)))[0]; const t = prev && notesOf(prev.path).speaker[b.slide]; if (!t) return json({ ok: false, error: 'no suggestion for this slide' }, 400); N.speaker[b.slide] = { text: t.text, at: nowIso(), from_slot: prev.slot }; }
|
|
351
|
+
else N.speaker[b.slide] = { text: String(b.text || ''), at: nowIso() };
|
|
352
|
+
return json({ ok: true });
|
|
353
|
+
}
|
|
354
|
+
function pin(b) {
|
|
355
|
+
const d = ctx.docs().find((x) => x.path === b.path); if (!d) return json({ ok: false, error: 'no such report document' }, 404);
|
|
356
|
+
const N = notesOf(b.path); const at = nowIso();
|
|
357
|
+
if (b.op === 'add') {
|
|
358
|
+
if (!String(b.text || '').trim()) return json({ ok: false, error: 'a note needs words' }, 400);
|
|
359
|
+
const p = P('n' + (++pinSeq), b.slide, Math.max(0, Math.min(100, +b.x)), Math.max(0, Math.min(100, +b.y)), String(b.text).trim(), 'you', 'kept', at, b.section);
|
|
360
|
+
N.pins.push(p); ctx.logEvent(b.path, { kind: 'pin', at, by: 'pm', op: 'add', id: p.id, slide: p.slide, text: p.text }); return json({ ok: true, pin: clone(p) });
|
|
361
|
+
}
|
|
362
|
+
const p = N.pins.find((x) => x.id === b.id); if (!p) return json({ ok: false, error: 'no such note' }, 404);
|
|
363
|
+
if (b.op === 'edit') { if (!String(b.text || '').trim()) return json({ ok: false, error: 'a note needs words' }, 400); p.text = String(b.text).trim(); }
|
|
364
|
+
else if (b.op === 'remove') N.pins = N.pins.filter((x) => x.id !== p.id);
|
|
365
|
+
else if (b.op === 'keep') { p.state = 'kept'; }
|
|
366
|
+
else if (b.op === 'dismiss') p.state = 'dismissed';
|
|
367
|
+
else return json({ ok: false, error: 'op is add, edit, remove, keep or dismiss' }, 400);
|
|
368
|
+
ctx.logEvent(b.path, { kind: 'pin', at, by: 'pm', op: b.op, id: p.id, slide: p.slide, text: p.text, ...(p.section ? { section: p.section } : {}) });
|
|
369
|
+
return json({ ok: true, pin: clone(p) });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* ── the design presses ── */
|
|
373
|
+
function press(k, b) {
|
|
374
|
+
const D = b.id ? design(b.id) : null; if (!D) return json({ ok: false, error: 'no such report' }, 404);
|
|
375
|
+
const at = nowIso();
|
|
376
|
+
if (k === 'describe') { if (!String(b.text || '').trim() && !D.description) return json({ ok: false, error: 'describe the report' }, 400); const first = !D.description; if (b.text) D.description = String(b.text).trim(); if (b.format === 'deck' || b.format === 'doc') D.format = b.format; if (first) D.log.push({ at, who: 'you', kind: 'describe', text: D.description }); if (D.state === 'none') D.state = 'draft'; return json({ ok: true }); }
|
|
377
|
+
if (k === 'sample') {
|
|
378
|
+
const id = 'x' + (D.samples.length + 1);
|
|
379
|
+
let s;
|
|
380
|
+
if (b.link) { const readable = /atlassian|google/.test(b.link); s = { id, name: String(b.link).replace(/^https?:\/\//, '').slice(0, 60), kind: 'link', source: b.link, took: readable ? 'A two-column slide for risks and asks' : null, thumb: null, readable }; }
|
|
381
|
+
else if (b.doc) { const dd = ctx.docs().find((x) => x.path === b.doc); if (!dd) return json({ ok: false, error: 'no such report document' }, 404); s = { id, name: dd.name + ', ' + dd.slot, kind: 'pmos-doc', source: b.doc, took: 'Decisions first, with owner and date', thumb: null, readable: true }; }
|
|
382
|
+
else { if (!b.data || !b.name) return json({ ok: false, error: 'a file and its name are required' }, 400); if (!['pdf', 'pptx', 'docx', 'image'].includes(b.kind)) return json({ ok: false, error: 'pdf, pptx, docx, png or jpg only' }, 400); if (b.data.length * 0.75 > 20 * 1024 * 1024) return json({ ok: false, error: 'larger than 20 MB' }, 413); s = { id, name: b.name, kind: b.kind, source: 'upload', took: b.kind === 'pptx' || b.kind === 'pdf' ? 'Big numbers on a white page, one point per slide' : 'A dense one-page layout with a chart on top', thumb: b.kind === 'image' ? 'data:image/png;base64,' + b.data.slice(0, 200000) : THUMB, readable: true }; }
|
|
383
|
+
D.samples.push(s); D.log.push({ at, who: 'you', kind: 'sample', sample: id, text: s.name }); if (D.state === 'none') D.state = 'draft';
|
|
384
|
+
return json({ ok: true, sample: clone(s) });
|
|
385
|
+
}
|
|
386
|
+
if (k === 'turn') {
|
|
387
|
+
settle(b.id); if (D.running) return json({ ok: false, error: 'a report-design job is already running for this report' }, 409);
|
|
388
|
+
const text = String(b.text || '').trim(); const points = Array.isArray(b.points) ? b.points : [];
|
|
389
|
+
D.log.push({ at, who: 'you', kind: 'turn', text: text || 'Do the requests I pointed at.', ...(points.length ? { points } : {}) });
|
|
390
|
+
D.running = { since: at, turn: text || 'Do the requests I pointed at.', points, t0: performance.now() }; if (D.state === 'none') D.state = 'draft';
|
|
391
|
+
return json({ ok: true, running: { since: at, turn: D.running.turn } });
|
|
392
|
+
}
|
|
393
|
+
const v = D.versions.find((x) => x.n === Number(b.n)); if (!v) return json({ ok: false, error: 'no such version' }, 404);
|
|
394
|
+
if (k === 'accept') {
|
|
395
|
+
D.saved = { version: v.n, at }; D.state = 'saved'; D.current = v.n;
|
|
396
|
+
if (!D.rules) D.rules = { same: ['The order of the ' + (v.format === 'deck' ? 'slides' : 'sections') + ': ' + v.outline.map((s) => s.title).join(', '), 'The look of D' + v.n + ': its headings, colours and spacing'], changes: ['The data: numbers, dates and statuses', 'Your words in the document'], learned: D.log.filter((l) => l.kind === 'turn').map((l) => l.text).slice(-3) };
|
|
397
|
+
D.log.push({ at, who: 'you', kind: 'accept', version: v.n, text: 'Saved D' + v.n + ' as the design.' });
|
|
398
|
+
for (const d of ctx.docs().filter((x) => x.setup === b.id)) { if (CYCLES[d.path] && CYCLES[d.path].designed) CYCLES[d.path].version = v.n; else delete CYCLES[d.path]; }
|
|
399
|
+
return json({ ok: true, saved: { version: v.n, at } });
|
|
400
|
+
}
|
|
401
|
+
if (k === 'revert') { D.current = v.n; D.log.push({ at, who: 'pmos', kind: 'revert', version: v.n, text: 'Back to D' + v.n + '. The next turn builds on it; the later versions stay in the strip.' }); return json({ ok: true, current: v.n }); }
|
|
402
|
+
return json({ ok: false, error: 'no such route' }, 404);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The routes. Returns a Response, or undefined for a route this file does not answer. */
|
|
406
|
+
export function rdRoute(method, u, body) {
|
|
407
|
+
const p = u.pathname; const qp = u.searchParams;
|
|
408
|
+
if (!p.startsWith('/api/report-design') && !p.startsWith('/api/report-notes')) return undefined;
|
|
409
|
+
if (method === 'GET') {
|
|
410
|
+
if (p === '/api/report-design') { const id = qp.get('id'); if (!ctx.setups().some((x) => x.id === id)) return json({ ok: false, error: 'no such report' }, 404); return json(designView(id)); }
|
|
411
|
+
if (p === '/api/report-design/version') { const id = qp.get('id'); const D = id && ctx.setups().some((x) => x.id === id) ? design(id) : null; const v = D && D.versions.find((x) => x.n === Number(qp.get('n'))); return v ? html(versionHtml(id, D, v)) : json({ ok: false, error: 'no such version' }, 404); }
|
|
412
|
+
if (p === '/api/report-design/cycle') return cycleView(qp.get('path'));
|
|
413
|
+
if (p === '/api/report-design/cycle-html') { const path = qp.get('path'); const C = ctx.docs().some((x) => x.path === path) ? cycleOf(path) : null; return C && C.designed ? html(cycleRender(path)) : json({ ok: false, error: 'this cycle has no design' }, 404); }
|
|
414
|
+
if (p === '/api/report-notes') return notesView(qp.get('path'));
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
if (method === 'POST') {
|
|
418
|
+
if (p === '/api/report-design/cycle-decision') return decide(body);
|
|
419
|
+
if (p === '/api/report-notes/speaker') return speaker(body);
|
|
420
|
+
if (p === '/api/report-notes/pin') return pin(body);
|
|
421
|
+
const k = p.replace('/api/report-design/', ''); if (['describe', 'sample', 'turn', 'accept', 'revert'].includes(k)) return press(k, body);
|
|
422
|
+
}
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
/** c3-demo-rs.js calls this once with its set-ups and documents. */
|
|
426
|
+
export function installRd(c) { ctx = c; for (const k of Object.keys(DESIGNS)) delete DESIGNS[k]; for (const k of Object.keys(CYCLES)) delete CYCLES[k]; for (const k of Object.keys(NOTES)) delete NOTES[k]; }
|
|
@@ -55,6 +55,8 @@ export function seedProposal(id, path, o) {
|
|
|
55
55
|
PROPS[id] = { path, prompt: o.prompt, after: o.after, hash: hashOf(o.after), before, state: o.state || 'proposed', why: o.why || null };
|
|
56
56
|
}
|
|
57
57
|
export function reviseDoc(path, by, why, text) { if (DOCS[path]) push(path, by, why, text); }
|
|
58
|
+
/* an Edit-log event the design routes add (c3-demo-rd.js: a pin, a design decision); nothing when the document is unknown */
|
|
59
|
+
export function logEvent(path, ev) { if (DOCS[path]) DOCS[path].events.push(ev); }
|
|
58
60
|
export const docText = (path) => (DOCS[path] ? DOCS[path].revs[DOCS[path].revs.length - 1].text : '');
|
|
59
61
|
|
|
60
62
|
/* the mockup's docHealth for a report that is still a draft: Fresh passes, Complete does not (a draft), Traced passes, and the claims check. */
|
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
* GET /api/report-export a small file per format (csv holds the real tables of the text)
|
|
8
8
|
* GET /api/docs the document list of the earlier wrapper plus the copies of the report documents
|
|
9
9
|
* POST /api/docs/create-copy|check|sync change the copy state in memory
|
|
10
|
+
* /api/report-design*, /api/report-notes* the design studio, the deck of a cycle and its notes (c3-demo-rd.js; initiative report-design-studio)
|
|
10
11
|
*
|
|
11
12
|
* 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
13
|
* 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
14
|
* 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
15
|
* 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
16
|
* 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 { seedDoc, seedProposal, reviseDoc, docText, logEvent } from './c3-demo-rep2.js';
|
|
18
|
+
import { installRd, rdRoute } from './c3-demo-rd.js';
|
|
17
19
|
import { scheduleLabel, nextDraftDate } from './c3-report-store.js';
|
|
18
20
|
import { FORMATS } from './c3-doc-formats.js';
|
|
19
21
|
|
|
@@ -241,6 +243,7 @@ const bodyOf = (init) => { try { return init && init.body ? JSON.parse(init.body
|
|
|
241
243
|
|
|
242
244
|
export function installRs() {
|
|
243
245
|
SETUPS.length = 0; DOCS.length = 0; seedAll();
|
|
246
|
+
installRd({ setups: () => SETUPS, docs: () => DOCS, docText, logEvent });
|
|
244
247
|
const prev = window.fetch;
|
|
245
248
|
window.fetch = (input, init) => {
|
|
246
249
|
try {
|
|
@@ -248,6 +251,7 @@ export function installRs() {
|
|
|
248
251
|
const method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
|
|
249
252
|
const u = new URL(raw, location.origin); const p = u.pathname;
|
|
250
253
|
if (u.origin === location.origin) {
|
|
254
|
+
const rd = rdRoute(method, u, bodyOf(init)); if (rd) return Promise.resolve(rd);
|
|
251
255
|
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 }));
|
|
252
256
|
if (method === 'GET' && p === '/api/report-documents') return Promise.resolve(json({ ok: true, docs: DOCS.map(docView) }));
|
|
253
257
|
if (method === 'GET' && p === '/api/report-export') return Promise.resolve(exportFile(u.searchParams));
|