create-pmos 0.196.0 → 0.198.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-ib-items-test.js +16 -8
- package/workspace/c3-ib-more-test.js +3 -2
- package/workspace/c3-ib-triage-backlog-test.js +1 -1
- package/workspace/c3-quiet-test.js +4 -3
- 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/index.md +1 -0
- package/workspace/guide/initiative-boards.md +9 -5
- 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-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-ib-items.js +26 -17
- package/workspace/public/pm/c3-ib-releases.js +4 -7
- package/workspace/public/pm/c3-ib-sprints.js +5 -8
- package/workspace/public/pm/c3-ib-triage.js +4 -9
- package/workspace/public/pm/c3-ini-life.css +2 -0
- package/workspace/public/pm/c3-initiatives.js +2 -1
- package/workspace/public/pm/c3-quiet.css +1 -1
- package/workspace/public/pm/c3-quiet.js +4 -5
- 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
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/* pm/c3-design-studio.js — the DESIGN STUDIO and the saved DESIGN BRIEF of a report (initiative report-design-studio; PRD sections 1 and 2;
|
|
2
|
+
* mockup 42-reportdeck.js, hashes #design, #design-point, #design-compare, #design-doc, #design-saved).
|
|
3
|
+
*
|
|
4
|
+
* openStudio({ ws, id, name, view, onClose }) the wide sheet: the conversation on the left (describe, add samples, turns, the PMOS lines
|
|
5
|
+
* that name each version), the live result on the right (the version strip, Compare two,
|
|
6
|
+
* go back to a version, point and say), the Deck / Document switch, This is it, Design brief
|
|
7
|
+
* openBrief({ ws, id, name }) the saved design in the report setup sheet: the description, the samples, the reference
|
|
8
|
+
* version, the rules PMOS wrote, and the cycles drafted to match it
|
|
9
|
+
* Entry points: "Design how it looks" on a setup row and "Refine the design" in a report document (c3-report-setup.js, c3-report-deck.js).
|
|
10
|
+
* A turn starts ONE report-design job on the server (POST /api/report-design/turn); the page shows it running and reads GET /api/report-design
|
|
11
|
+
* until the version lands. The page can be left: the job goes on. Nothing here sends anything outside the workspace. */
|
|
12
|
+
import { el } from './dom.js';
|
|
13
|
+
import { icon, pbtn, btn, chip, sect, put, lk } from './c3-ini-ui.js';
|
|
14
|
+
import { openSheet, closeSheet } from './c3-sheet.js';
|
|
15
|
+
import { toast } from './c3-toast.js';
|
|
16
|
+
import { RS, scheduleLabel } from './c3-report-store.js';
|
|
17
|
+
import { formatOf } from './c3-doc-formats.js';
|
|
18
|
+
import { RD, failed, splitRender, slideCard, frame, wireFrame, diffOutlines, ensureDeckCss, slotWord } from './c3-report-deck.js';
|
|
19
|
+
|
|
20
|
+
const MAX = 20 * 1024 * 1024;
|
|
21
|
+
const KIND_OF = (name) => { const e = String(name || '').toLowerCase().split('.').pop(); return e === 'pdf' ? 'pdf' : e === 'pptx' ? 'pptx' : e === 'docx' ? 'docx' : /^(png|jpe?g)$/.test(e) ? 'image' : ''; };
|
|
22
|
+
const SICON = { pdf: 'pdf', pptx: 'img', docx: 'doc', image: 'img', link: 'link', 'pmos-doc': 'doc' };
|
|
23
|
+
const SWORD = { pdf: 'Upload, PDF', pptx: 'Upload, .pptx', docx: 'Upload, .docx', image: 'Upload, image', link: 'Link', 'pmos-doc': 'Past PMOS document' };
|
|
24
|
+
const hhmm = (iso) => { const m = /T(\d{2}):(\d{2})/.exec(String(iso || '')); return m ? m[1] + ':' + m[2] : ''; };
|
|
25
|
+
const dayAt = (iso) => { const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/.exec(String(iso || '')); return m ? slotWord(m[1]) + ', ' + m[2] + ':' + m[3] : String(iso || ''); };
|
|
26
|
+
const short = (t, n = 60) => { const s = String(t || '').replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
27
|
+
const hint = (k) => (/headline|metric/.test(k) ? 'Hover a value for its detail. Click a number to see its source.' : /list/.test(k) ? 'Click a status to see where it comes from.' : 'Click any part of the preview to attach a request to it.');
|
|
28
|
+
|
|
29
|
+
/* one studio session per report setup: what the PM points at waits here until the next turn */
|
|
30
|
+
const LOCAL = {};
|
|
31
|
+
const local = (id) => LOCAL[id] || (LOCAL[id] = { view: null, cmp: false, cmpA: null, sidx: 0, point: true, markers: [], draft: null, mOpen: null, panel: false, text: '', fmt: null });
|
|
32
|
+
|
|
33
|
+
function sampleChip(s) {
|
|
34
|
+
const th = s.thumb && /^data:image\//.test(s.thumb) ? el('img', { class: 'rd-dks-thumb img', src: s.thumb, alt: '' }) : el('span', { class: 'rd-dks-thumb ' + (s.kind === 'pptx' || s.kind === 'image' ? 'deck' : 'page'), 'aria-hidden': 'true' }, el('i'), el('i'), el('i'));
|
|
35
|
+
return el('div', { class: 'rd-dks-smp', 'data-rd-sample': s.id }, th, el('div', null, el('b', null, icon(SICON[s.kind] || 'doc', 'sm'), s.name), el('span', { class: 'dim mono' }, SWORD[s.kind] || s.kind),
|
|
36
|
+
s.readable === false ? el('p', { class: 'rd-warn' }, 'PMOS could not read it with your tools. It is kept, but it does not shape the design.')
|
|
37
|
+
: el('p', null, el('span', { class: 'dim' }, 'PMOS took: '), s.took || 'PMOS reads it on the next turn.')));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ── the studio ── */
|
|
41
|
+
export function openStudio({ ws, id, name, view, onClose } = {}) {
|
|
42
|
+
ensureDeckCss();
|
|
43
|
+
const K = local(id); if (view != null) { K.view = view; K.cmp = false; }
|
|
44
|
+
const S = { D: null, setup: null, err: '', cache: {}, timer: null, closed: false, busy: false };
|
|
45
|
+
const body = el('div', { class: 'brief rs rd-dk rd-dks-wrap', 'data-rd-studio': id });
|
|
46
|
+
const ui = openSheet({ title: 'Design studio', icon: 'spark', wide: true, dw: true, cls: 'rd-wide', body, onClose: () => { S.closed = true; clearTimeout(S.timer); if (onClose) onClose(); } });
|
|
47
|
+
|
|
48
|
+
async function load(keepView) {
|
|
49
|
+
const r = await RD.design(ws, id);
|
|
50
|
+
if (!r || r.ok === false) { S.err = (r && r.error) || 'The design could not be read.'; paint(); return; }
|
|
51
|
+
S.D = r.design; S.setup = r.setup || { id, name };
|
|
52
|
+
if (!K.fmt || !S.D.versions.length) K.fmt = S.D.format || 'deck';
|
|
53
|
+
if (!keepView || K.view == null || !S.D.versions.some((v) => v.n === K.view)) K.view = S.D.current;
|
|
54
|
+
if (S.D.running) poll(); else clearTimeout(S.timer);
|
|
55
|
+
if (K.view != null) await render(K.view);
|
|
56
|
+
if (K.cmp) await render(cmpA());
|
|
57
|
+
paint();
|
|
58
|
+
}
|
|
59
|
+
async function render(n) {
|
|
60
|
+
if (n == null || S.cache[n]) return;
|
|
61
|
+
try { S.cache[n] = splitRender(await RD.version(ws, id, n)); } catch (e) { const style = ''; S.cache[n] = { err: (e && e.message) || 'not readable', slides: [], secs: [], style, body: '' }; }
|
|
62
|
+
}
|
|
63
|
+
function poll() { clearTimeout(S.timer); S.timer = setTimeout(async () => { if (S.closed) return; const was = S.D && S.D.current; await load(false); if (S.D && !S.D.running && S.D.current !== was) { K.markers = []; K.draft = null; scrollLog(); } }, 1500); }
|
|
64
|
+
const ver = (n) => (S.D.versions || []).find((v) => v.n === n);
|
|
65
|
+
const cmpA = () => { const b = S.D.current; const a = K.cmpA != null && K.cmpA !== b && ver(K.cmpA) ? K.cmpA : (S.D.versions.filter((v) => v.n < b).pop() || S.D.versions.find((v) => v.n !== b) || {}).n; return a; };
|
|
66
|
+
const scrollLog = () => requestAnimationFrame(() => { const l = body.querySelector('.rd-dks-log'); if (l) l.scrollTop = l.scrollHeight; });
|
|
67
|
+
const vlabel = (v) => (v ? (v.turn ? short(v.turn, 48) : v.n === 1 ? 'From your description' : 'Your turn') : '');
|
|
68
|
+
|
|
69
|
+
/* ── the conversation ── */
|
|
70
|
+
function msg(l) {
|
|
71
|
+
const you = l.who === 'you';
|
|
72
|
+
const sm = l.kind === 'sample' ? (S.D.samples || []).filter((s) => (l.sample ? s.id === l.sample : String(l.text || '').includes(s.name))) : [];
|
|
73
|
+
const v = l.version != null ? ver(l.version) : null;
|
|
74
|
+
return el('li', { class: 'rd-dks-msg ' + (you ? 'you' : 'pmos') + (l.kind === 'error' ? ' err' : ''), 'data-rd-log': l.kind },
|
|
75
|
+
el('span', { class: 'ds-av ' + (you ? 'h' : l.kind === 'error' ? 'w' : 'a') }, you ? icon('user', 'sm') : icon(l.kind === 'error' ? 'alert' : 'spark', 'sm')),
|
|
76
|
+
el('div', { class: 'bub' }, el('div', { class: 'who' }, you ? 'You' : 'PMOS'), l.text && !(l.kind === 'sample' && sm.length) ? el('p', null, l.text) : null, sm.map(sampleChip),
|
|
77
|
+
l.points && l.points.length ? chip(icon('target', 'sm'), 'Pointed at: marker ' + l.points.map((p) => p.n).join(', ') + ' on the preview') : null,
|
|
78
|
+
v ? lk({ class: 'rd-dks-ver', 'data-rd-ver': String(v.n), 'aria-pressed': String(K.view === v.n && !K.cmp), onclick: () => look(v.n) }, icon('img', 'sm'), 'D' + v.n + ': ' + vlabel(v)) : null));
|
|
79
|
+
}
|
|
80
|
+
function runningMsg() {
|
|
81
|
+
const n = (S.D.versions.length ? Math.max(...S.D.versions.map((v) => v.n)) : 0) + 1;
|
|
82
|
+
return el('li', { class: 'rd-dks-msg pmos', 'data-rd-running': '' }, el('span', { class: 'ds-av a' }, icon('spark', 'sm')),
|
|
83
|
+
el('div', { class: 'bub' }, el('div', { class: 'who' }, 'PMOS'), el('p', null, 'Making D' + n + ' from this cycle\'s data. This takes a minute or two. You can close the studio; the version lands here when the job ends.')));
|
|
84
|
+
}
|
|
85
|
+
function panel() {
|
|
86
|
+
if (!K.panel) return null;
|
|
87
|
+
const file = el('input', { type: 'file', class: 'rd-hide', accept: '.pdf,.pptx,.docx,.png,.jpg,.jpeg', 'data-rd-file': '', onChange: (e) => upload(e.target.files && e.target.files[0]) });
|
|
88
|
+
const link = el('input', { type: 'text', 'data-rd-link': '', placeholder: 'Paste a Google Drive or Confluence link', 'aria-label': 'Link to a sample', onKeyDown: (e) => { if (e.key === 'Enter') addLink(); } });
|
|
89
|
+
const docs = (RS.docs || []).filter((d) => d.setup !== id || d.state === 'issued').slice(0, 3);
|
|
90
|
+
return el('div', { class: 'rd-dks-panel', 'data-rd-panel': '' }, el('div', { class: 'ds-h' }, 'Add a sample'),
|
|
91
|
+
btn({ class: 'rd-dks-opt', 'data-rd-smp': 'file', onclick: () => file.click() }, icon('drop', 'sm'), el('span', null, 'Upload a past deck or document', el('br'), el('span', { class: 'dim rd-small' }, 'PDF, .pptx, .docx or a screenshot, up to 20 MB'))), file,
|
|
92
|
+
el('div', { class: 'ds-whyadd' }, link, btn({ class: 'ds-btn', 'data-rd-smp': 'link', onclick: () => addLink() }, 'Add')),
|
|
93
|
+
docs.map((d) => btn({ class: 'rd-dks-opt', 'data-rd-smp': 'doc', 'data-v': d.path, onclick: () => addDoc(d) }, icon('doc', 'sm'), el('span', null, 'Pick a past PMOS document', el('br'), el('span', { class: 'dim rd-small' }, d.name + ', ' + slotWord(d.slot))))),
|
|
94
|
+
el('div', { class: 'dim rd-small' }, 'PMOS reads a sample to learn how it looks. It does not copy its numbers. A link is read only if your tools can open it.'));
|
|
95
|
+
}
|
|
96
|
+
async function sampleDone(r) {
|
|
97
|
+
if (!r || !r.ok) { failed(r, 'The sample was not added'); return; }
|
|
98
|
+
K.panel = false; await load(true); scrollLog(); toast('Added the sample. Say what to take from it, then make the next version.');
|
|
99
|
+
}
|
|
100
|
+
function upload(f) {
|
|
101
|
+
if (!f) return;
|
|
102
|
+
const kind = KIND_OF(f.name); if (!kind) { toast({ text: 'PMOS reads PDF, .pptx, .docx, PNG or JPG files.', tone: 'error' }); return; }
|
|
103
|
+
if (f.size > MAX) { toast({ text: 'The file is larger than 20 MB. Add a smaller copy, or a link.', tone: 'error' }); return; }
|
|
104
|
+
const rd = new FileReader();
|
|
105
|
+
rd.onload = async () => { const data = String(rd.result || '').replace(/^data:[^,]*,/, ''); sampleDone(await RD.sample({ ws, id, name: f.name, kind, data })); };
|
|
106
|
+
rd.readAsDataURL(f);
|
|
107
|
+
}
|
|
108
|
+
async function addLink() { const i = body.querySelector('[data-rd-link]'); const v = i && i.value.trim(); if (!v) { if (i) i.focus(); return; } sampleDone(await RD.sample({ ws, id, link: v })); }
|
|
109
|
+
async function addDoc(d) { sampleDone(await RD.sample({ ws, id, doc: d.path })); }
|
|
110
|
+
|
|
111
|
+
function chat() {
|
|
112
|
+
const D = S.D; const first = !D.versions.length;
|
|
113
|
+
const ta = el('textarea', { 'data-rd-in': '', 'aria-label': first ? 'Describe the report' : 'Tell PMOS what to change',
|
|
114
|
+
placeholder: first ? 'Describe the report in plain words: who reads it, what they need to see first, what must stand out.' : K.fmt === 'deck' ? 'Tell PMOS what to change, for example: denser, like slide 3 of my sample' : 'Tell PMOS what to change, for example: put the chart before the summary',
|
|
115
|
+
onInput: (e) => { K.text = e.target.value; }, onKeyDown: (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); send(); } } });
|
|
116
|
+
ta.value = K.text;
|
|
117
|
+
return el('div', { class: 'rd-dks-chat' }, el('div', { class: 'rd-dks-desc' }, el('span', { class: 'dim mono' }, 'DESCRIBE, ADD SAMPLES, REFINE'),
|
|
118
|
+
(D.samples || []).length ? el('div', { class: 'chips' }, D.samples.map((s) => chip(icon(SICON[s.kind] || 'doc', 'sm'), s.name))) : null),
|
|
119
|
+
el('ul', { class: 'rd-dks-log', 'data-rd-chat': '' }, (D.log || []).map(msg), D.running ? runningMsg() : null,
|
|
120
|
+
!(D.log || []).length && !D.running ? el('li', { class: 'fold' }, icon('info', 'sm'), ' Start with who reads the report and what they need. Add a past deck or document if you have one.') : null),
|
|
121
|
+
panel(),
|
|
122
|
+
el('div', { class: 'rd-dks-in' }, ta, K.markers.length ? el('div', { class: 'chips' }, chip(icon('target', 'sm'), K.markers.length + (K.markers.length === 1 ? ' request' : ' requests') + ' on the preview go with this turn')) : null,
|
|
123
|
+
el('div', { class: 'ds-acts rd-between' }, btn({ class: 'ds-btn', 'data-rd-act': 'panel', 'aria-expanded': String(K.panel), onclick: () => { K.panel = !K.panel; paint(); } }, icon('plus', 'sm'), 'Add a sample'),
|
|
124
|
+
btn({ class: 'ds-btn ag', 'data-rd-act': 'send', disabled: !!D.running || S.busy, onclick: () => send() }, icon('spark', 'sm'), first ? 'Make the first version' : 'Make the next version')),
|
|
125
|
+
el('div', { class: 'dim rd-small' }, 'Each turn makes a new version. The data stays this cycle\'s; only how it looks changes.')));
|
|
126
|
+
}
|
|
127
|
+
async function send(textIn) {
|
|
128
|
+
const D = S.D; const i = body.querySelector('[data-rd-in]');
|
|
129
|
+
let text = String(textIn != null ? textIn : (i ? i.value : K.text) || '').trim();
|
|
130
|
+
if (!text && K.markers.length) text = 'Do the requests I pointed at.';
|
|
131
|
+
if (!text) { if (i) i.focus(); return; }
|
|
132
|
+
if (D.running) { toast('A version is being made. Wait for it to land.'); return; }
|
|
133
|
+
S.busy = true; paint();
|
|
134
|
+
let r;
|
|
135
|
+
if (!D.description) { r = await RD.describe({ ws, id, text, format: K.fmt }); if (!r || !r.ok) { S.busy = false; paint(); failed(r, 'The description was not saved'); return; } }
|
|
136
|
+
const points = K.markers.map((m) => ({ n: m.n, slide: m.slide, x: m.x, y: m.y, text: m.text }));
|
|
137
|
+
r = await RD.turn({ ws, id, text: D.description ? text : 'Make the first version from my description.', points });
|
|
138
|
+
S.busy = false;
|
|
139
|
+
if (!r || !r.ok) { paint(); if (r && (r.status === 409 || /run/i.test(String(r.error || '')))) toast('A version is being made. Wait for it to land.'); else failed(r, 'PMOS did not start'); return; }
|
|
140
|
+
/* the preview follows the version this turn makes: a fast job can land it before this load, and keepView would hold the old one */
|
|
141
|
+
K.text = ''; K.markers = []; K.draft = null; K.mOpen = null; K.view = null; K.cmp = false;
|
|
142
|
+
await load(true); scrollLog();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ── the result ── */
|
|
146
|
+
function look(n) { K.view = n; K.cmp = false; render(n).then(paint); }
|
|
147
|
+
function strip() {
|
|
148
|
+
const D = S.D; const cur = D.current;
|
|
149
|
+
return el('div', { class: 'rd-dks-vers' }, el('div', { class: 'seg', role: 'group', 'aria-label': 'Versions', 'data-rd-versions': '' }, D.versions.map((v) => btn({ 'data-rd-v': String(v.n), 'aria-pressed': String(K.view === v.n && !K.cmp), title: vlabel(v), onclick: () => look(v.n) }, 'D' + v.n, D.saved && D.saved.version === v.n ? icon('check', 'sm') : null))),
|
|
150
|
+
el('span', { class: 'chips2' }, K.view !== cur && !K.cmp ? btn({ class: 'ds-btn', 'data-rd-act': 'revert', onclick: () => revert(K.view) }, icon('undo', 'sm'), 'Go back to D' + K.view) : null,
|
|
151
|
+
D.versions.length > 1 ? btn({ class: 'ds-btn', 'data-rd-act': 'cmp', 'aria-pressed': String(K.cmp), onclick: async () => { K.cmp = !K.cmp; if (K.cmp) { K.view = cur; await render(cur); await render(cmpA()); } paint(); } }, icon('cols', 'sm'), K.cmp ? 'Stop comparing' : 'Compare two') : null));
|
|
152
|
+
}
|
|
153
|
+
async function revert(n) {
|
|
154
|
+
const r = await RD.revert({ ws, id, n }); if (!r || !r.ok) { failed(r, 'It did not go back'); return; }
|
|
155
|
+
await load(true); toast('Back to D' + n + '. The next turn builds on it. The later versions stay in the strip.');
|
|
156
|
+
}
|
|
157
|
+
function markers(key, scaleBox) {
|
|
158
|
+
if (!K.point) return [];
|
|
159
|
+
const L = K.markers.filter((m) => m.slide === key).map((m) => { const b = btn({ class: 'rd-pindot a rd-dks-mk' + (K.mOpen === m.id ? ' on' : ''), 'data-rd-mk': String(m.n), 'aria-label': 'Request ' + m.n + ': ' + m.text, onclick: () => { K.mOpen = K.mOpen === m.id ? null : m.id; paint(); } }, String(m.n)); place(b, m, scaleBox); return b; });
|
|
160
|
+
if (K.draft && K.draft.slide === key) { const d = el('span', { class: 'rd-pindot a on rd-dks-mk', 'aria-hidden': 'true' }, String(K.markers.length + 1)); place(d, K.draft, scaleBox); L.push(d); }
|
|
161
|
+
return L;
|
|
162
|
+
}
|
|
163
|
+
const place = (n, m, box) => { if (box) { n.style.setProperty('--rp-x', Math.round(box.left + box.width * m.x / 100) + 'px'); n.style.setProperty('--rp-y', Math.round(box.top + box.height * m.y / 100) + 'px'); } else { n.style.setProperty('--rp-x', m.x + '%'); n.style.setProperty('--rp-y', m.y + '%'); } };
|
|
164
|
+
function pointSect(key) {
|
|
165
|
+
const ms = K.markers.filter((m) => m.slide === key);
|
|
166
|
+
const draft = K.draft && K.draft.slide === key ? el('div', { class: 'ds-whyadd' }, el('input', { type: 'text', 'data-rd-mkdraft': '', placeholder: 'What should change here? For example: make this a table', 'aria-label': 'Request for this spot', onKeyDown: (e) => { if (e.key === 'Enter') saveMk(); } }),
|
|
167
|
+
btn({ class: 'ds-btn pri', 'data-rd-act': 'mksave', onclick: () => saveMk() }, 'Attach'), btn({ class: 'ds-btn', 'data-rd-act': 'mkcancel', onclick: () => { K.draft = null; paint(); } }, 'Cancel')) : null;
|
|
168
|
+
return sect({ ic: 'target', title: 'Point and say', count: ms.length, attrs: { 'data-rd-point': key },
|
|
169
|
+
right: el('label', { class: 'ds-tg' }, el('input', { type: 'checkbox', checked: K.point, 'data-rd-ck': 'point', onChange: (e) => { K.point = e.target.checked; K.draft = null; paint(); } }), 'On') },
|
|
170
|
+
ms.length ? el('ul', { class: 'ds-log rd-notes' }, ms.map((m) => el('li', { class: 'rd-note' + (K.mOpen === m.id ? ' on' : ''), 'data-rd-mkrow': String(m.n) },
|
|
171
|
+
btn({ class: 'rd-pindot a', 'aria-label': 'Request ' + m.n, onclick: () => { K.mOpen = K.mOpen === m.id ? null : m.id; paint(); } }, String(m.n)),
|
|
172
|
+
el('div', { class: 'ds-le' }, el('div', { class: 'l1' }, el('b', null, m.text), el('span', { class: 'when' }, 'Waiting')),
|
|
173
|
+
K.mOpen === m.id ? [el('div', { class: 'who' }, el('span', null, 'PMOS makes it in the next version, with your other requests.')), el('div', { class: 'ds-acts' }, btn({ class: 'ds-btn ag', 'data-rd-act': 'mkgo', onclick: () => send(K.text.trim() || 'Do the requests I pointed at.') }, icon('spark', 'sm'), 'Make the next version'), btn({ class: 'ds-btn', 'data-rd-act': 'mkdrop', onclick: () => { K.markers = K.markers.filter((x) => x.id !== m.id).map((x, k) => ({ ...x, n: k + 1 })); K.mOpen = null; paint(); } }, icon('trash', 'sm'), 'Remove'))] : null)))) : null,
|
|
174
|
+
draft, el('div', { class: 'dim rd-small' }, K.point ? 'Click any part of the preview to attach a request to it.' : 'Point and say is off.'));
|
|
175
|
+
}
|
|
176
|
+
function saveMk() {
|
|
177
|
+
const i = body.querySelector('[data-rd-mkdraft]'); const t = i && i.value.trim(); if (!t) { if (i) i.focus(); return; }
|
|
178
|
+
const m = { id: 'm' + Date.now(), n: K.markers.length + 1, slide: K.draft.slide, x: K.draft.x, y: K.draft.y, text: t };
|
|
179
|
+
K.markers.push(m); K.draft = null; K.mOpen = m.id; paint();
|
|
180
|
+
}
|
|
181
|
+
const pointHands = (getBox) => ({ place: (e, doc) => { if (!K.point) return false; const b = getBox(e, doc); if (!b) return false; K.draft = { slide: b.id, x: Math.round(b.x), y: Math.round(b.y) }; paint(); const i = body.querySelector('[data-rd-mkdraft]'); if (i) i.focus(); return true; } });
|
|
182
|
+
function preview() {
|
|
183
|
+
const D = S.D;
|
|
184
|
+
if (!D.versions.length) return [D.running ? el('div', { class: 'fold a', role: 'status' }, icon('clock', 'sm'), ' PMOS is making the first version.') : null,
|
|
185
|
+
el('div', { class: 'fold rd-none', 'data-rd-noversion': '' }, icon('info', 'sm'), ' No version yet. Describe the report on the left, add a sample if you have one, then press Make the first version.')];
|
|
186
|
+
if (K.cmp) return [strip(), compare()];
|
|
187
|
+
const v = ver(K.view) || ver(D.current); const R = S.cache[v.n];
|
|
188
|
+
const capline = el('div', { class: 'dim rd-small', 'data-rd-vcap': String(v.n) }, el('b', { class: 'rd-fg' }, 'D' + v.n), ' · ' + vlabel(v) + ' · made ' + hhmm(v.made_at) + '. Built with this cycle\'s real data.' + (v.n !== D.current ? ' You are looking at an older version.' : ''));
|
|
189
|
+
const run = D.running ? el('div', { class: 'fold a', role: 'status' }, icon('clock', 'sm'), ' PMOS is making the next version. This one stays on screen until it lands.') : null;
|
|
190
|
+
if (!R) return [strip(), capline, el('div', { class: 'fold', role: 'status' }, icon('clock', 'sm'), ' Reading D' + v.n + '.')];
|
|
191
|
+
if (R.err) return [strip(), capline, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), ' D' + v.n + ' could not be read: ' + R.err + '.')];
|
|
192
|
+
if (R.format === 'doc') {
|
|
193
|
+
const card = el('div', { class: 'rd-docf rd-dks-doc' + (K.point ? ' placing' : ''), 'data-rd-sbig': 'doc' }); const layer = el('div', { class: 'rd-doclayer' });
|
|
194
|
+
const box = (e) => { const sec = e.target.closest && e.target.closest('section.pmos-sec'); if (!sec) return null; const r = sec.getBoundingClientRect(); return { id: sec.getAttribute('data-sec-id'), x: (e.clientX - r.left) / r.width * 100, y: (e.clientY - r.top) / r.height * 100 }; };
|
|
195
|
+
card.append(frame(R.style, R.body, { title: 'D' + v.n + ', the document', cls: 'pmos-doc', focusable: true, onLoad: (doc, fr) => {
|
|
196
|
+
const fit = () => { fr.style.setProperty('--rp-h', Math.max(200, doc.documentElement.scrollHeight) + 'px'); const L = []; for (const s of R.secs) { const n = doc.querySelector('section.pmos-sec[data-sec-id="' + s.id.replace(/"/g, '') + '"]'); if (n) L.push(...markers(s.id, n.getBoundingClientRect())); } put(layer, ...L); };
|
|
197
|
+
fit(); setTimeout(fit, 120); wireFrame(doc, { ...pointHands(box), more: fit });
|
|
198
|
+
} }), layer);
|
|
199
|
+
const key = K.draft ? K.draft.slide : (K.markers[0] || {}).slide || (R.secs[0] || {}).id || 'doc';
|
|
200
|
+
return [strip(), capline, run, card, pointSect(key)];
|
|
201
|
+
}
|
|
202
|
+
if (!R.slides.length) return [strip(), capline, el('div', { class: 'fold w' }, icon('alert', 'sm'), ' D' + v.n + ' has no slide.')];
|
|
203
|
+
K.sidx = Math.min(K.sidx, R.slides.length - 1); const s = R.slides[K.sidx];
|
|
204
|
+
const big = slideCard(R, s, { cls: 'rd-big' + (K.point ? ' placing' : ''), attrs: { 'data-rd-sbig': s.id }, focusable: true, over: markers(s.id), wire: (doc) => wireFrame(doc, pointHands((e) => ({ id: s.id, x: e.clientX / 1280 * 100, y: e.clientY / 720 * 100 }))) });
|
|
205
|
+
return [strip(), capline, run, thumbStrip(R, K.sidx, (i) => { K.sidx = i; K.draft = null; paint(); }),
|
|
206
|
+
el('div', { class: 'rd-dk-cap' }, el('b', { 'data-rd-scap': '' }, 'Slide ' + s.n + ' of ' + R.slides.length + ': ' + s.title), el('span', { class: 'dim rd-small' }, hint(s.kind))), big, pointSect(s.id)];
|
|
207
|
+
}
|
|
208
|
+
function thumbStrip(R, cur, pick) {
|
|
209
|
+
return el('nav', { class: 'rd-dk-strip h', 'aria-label': 'Slides', 'data-rd-sstrip': '' }, R.slides.map((s, i) => btn({ class: 'rd-dk-th', 'aria-current': String(cur === i), 'aria-label': 'Slide ' + s.n + ': ' + s.title, 'data-rd-sgo': String(i), onclick: () => pick(i) },
|
|
210
|
+
el('div', { inert: '' }, slideCard(R, s, { cls: 'rd-thumb' })), el('span', { class: 'rd-dk-thn' }, String(s.n)))));
|
|
211
|
+
}
|
|
212
|
+
function compare() {
|
|
213
|
+
const D = S.D; const b = D.current; const a = cmpA(); const RA = S.cache[a]; const RB = S.cache[b];
|
|
214
|
+
const seg = el('div', { class: 'rd-dk-cap' }, el('span', { class: 'chips2 rd-wrap' }, 'Compare ', el('div', { class: 'seg', role: 'group', 'aria-label': 'Compare with', 'data-rd-cmpa': '' }, D.versions.filter((v) => v.n !== b).map((v) => btn({ 'data-rd-a': String(v.n), 'aria-pressed': String(a === v.n), onclick: async () => { K.cmpA = v.n; await render(v.n); paint(); } }, 'D' + v.n))), ' with ', el('b', null, 'D' + b)));
|
|
215
|
+
if (!RA || !RB) return el('div', null, seg, el('div', { class: 'fold', role: 'status' }, icon('clock', 'sm'), ' Reading both versions.'));
|
|
216
|
+
const va = ver(a); const vb = ver(b);
|
|
217
|
+
const ch = diffOutlines(va.outline, vb.outline); if (vb.turn) ch.push('Your turn for D' + b + ': ' + short(vb.turn, 90)); if (va.format !== vb.format) ch.unshift('D' + a + ' is a ' + (va.format === 'deck' ? 'deck' : 'document') + ', D' + b + ' a ' + (vb.format === 'deck' ? 'deck' : 'document'));
|
|
218
|
+
ch.push('Same data in both: this cycle\'s draft');
|
|
219
|
+
const head = (n, v) => el('div', { class: 'chips2 rd-cmph' }, el('b', null, 'D' + n), el('span', { class: 'dim' }, vlabel(v)));
|
|
220
|
+
let cols; let tail = null;
|
|
221
|
+
if (RB.format === 'doc' || RA.format === 'doc') {
|
|
222
|
+
const docOf = (R) => (R.format === 'doc' ? el('div', { class: 'rd-docf sm' }, frame(R.style, R.body, { title: 'Version', cls: 'pmos-doc', onLoad: (doc, fr) => fr.style.setProperty('--rp-h', Math.max(200, doc.documentElement.scrollHeight) + 'px') })) : slideCard(R, R.slides[0] || { id: 'x', n: 1, title: '', html: '' }));
|
|
223
|
+
cols = [el('div', { class: 'rd-cmpc' }, head(a, va), docOf(RA)), el('div', { class: 'rd-cmpc' }, head(b, vb), docOf(RB))];
|
|
224
|
+
} else {
|
|
225
|
+
if (!RB.slides[K.sidx]) K.sidx = 0; const B = RB.slides[K.sidx]; const A = RA.slides.find((x) => x.id === B.id);
|
|
226
|
+
cols = [el('div', { class: 'rd-cmpc' }, head(a, va), A ? slideCard(RA, A) : el('div', { class: 'fold rd-none' }, icon('info', 'sm'), ' Not in D' + a + '.')), el('div', { class: 'rd-cmpc' }, head(b, vb), slideCard(RB, B))];
|
|
227
|
+
tail = thumbStrip(RB, K.sidx, (i) => { K.sidx = i; paint(); });
|
|
228
|
+
seg.append(el('span', { class: 'dim rd-small' }, 'Slide: ' + B.title + '. Pick another slide below.'));
|
|
229
|
+
}
|
|
230
|
+
return el('div', { class: 'rd-stack', 'data-rd-compare': '' }, seg, el('div', { class: 'rd-cmpg' }, ...cols),
|
|
231
|
+
sect({ ic: 'cols', title: 'What changed from D' + a + ' to D' + b, count: ch.length, attrs: { 'data-rd-changed': '' } }, el('ul', { class: 'chgl' }, ch.map((t) => el('li', null, icon('edit', 'sm'), el('span', null, t), el('span'))))), tail);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* ── the bar ── */
|
|
235
|
+
async function setFmt(f) {
|
|
236
|
+
if (K.fmt === f) return; K.fmt = f;
|
|
237
|
+
if (S.D.description && S.D.format !== f) { const r = await RD.describe({ ws, id, text: S.D.description, format: f }); if (!r || !r.ok) { failed(r, 'The format was not changed'); return; } await load(true); toast('The next version is ' + (f === 'deck' ? 'a slide deck' : 'a document') + '. Make it from the conversation.'); return; }
|
|
238
|
+
paint();
|
|
239
|
+
}
|
|
240
|
+
async function accept() {
|
|
241
|
+
const n = K.view != null ? K.view : S.D.current; if (n == null) { toast('Make a version first.'); return; }
|
|
242
|
+
const r = await RD.accept({ ws, id, n }); if (!r || !r.ok) { failed(r, 'The design was not saved'); return; }
|
|
243
|
+
await load(true); toast('Saved D' + n + ' as the design of this report. PMOS drafts each cycle to match it. Nothing is sent.');
|
|
244
|
+
}
|
|
245
|
+
function paint() {
|
|
246
|
+
if (S.closed) return;
|
|
247
|
+
if (!S.D) { put(body, S.err ? el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), ' ' + S.err) : el('div', { class: 'fold', role: 'status' }, icon('clock', 'sm'), ' Reading the design.')); return; }
|
|
248
|
+
const D = S.D; const nm = (S.setup && S.setup.name) || name || 'Report';
|
|
249
|
+
const sb = body.closest('.sb'); const y = sb ? sb.scrollTop : 0; const log = body.querySelector('.rd-dks-log'); const ly = log ? log.scrollTop : null;
|
|
250
|
+
const focus = document.activeElement && body.contains(document.activeElement) && document.activeElement.matches('[data-rd-in],[data-rd-link]') ? document.activeElement.getAttribute('data-rd-in') != null ? '[data-rd-in]' : '[data-rd-link]' : null;
|
|
251
|
+
put(body, el('div', { class: 'rd-dk-bar' }, el('div', { class: 'chips2 rd-wrap' }, el('div', { class: 'seg', role: 'group', 'aria-label': 'Report type' },
|
|
252
|
+
btn({ 'data-rd-fmt': 'deck', 'aria-pressed': String(K.fmt === 'deck'), onclick: () => setFmt('deck') }, icon('img', 'sm'), 'Deck'), btn({ 'data-rd-fmt': 'doc', 'aria-pressed': String(K.fmt === 'doc'), onclick: () => setFmt('doc') }, icon('doc', 'sm'), 'Document')),
|
|
253
|
+
el('b', null, nm), D.saved ? el('span', { class: 'chip2 ds-good', 'data-rd-saved': String(D.saved.version) }, icon('check', 'sm'), 'Saved design: D' + D.saved.version) : el('span', { class: 'chip2', 'data-rd-saved': '' }, 'Not saved yet')),
|
|
254
|
+
el('div', { class: 'chips2' }, pbtn('primary', 'This is it', 'check', { 'data-rd-act': 'accept', disabled: !D.versions.length, onclick: () => accept() }),
|
|
255
|
+
D.saved ? pbtn('ghost', 'Design brief', 'doc', { 'data-rd-act': 'brief', onclick: () => openBrief({ ws, id, name: nm }) }) : null)),
|
|
256
|
+
el('div', { class: 'fold' }, icon('info', 'sm'), ' No template to fill in. Describe the report, add samples, then refine until it looks right. PMOS keeps what you settle on and drafts every cycle to match it. Nothing is sent.'),
|
|
257
|
+
el('div', { class: 'rd-dks' }, chat(), el('div', { class: 'rd-dks-out', 'data-rd-preview': '' }, ...[].concat(preview()))));
|
|
258
|
+
if (sb) sb.scrollTop = y; const l2 = body.querySelector('.rd-dks-log'); if (l2) l2.scrollTop = ly == null ? l2.scrollHeight : ly;
|
|
259
|
+
if (focus) { const n = body.querySelector(focus); if (n) n.focus(); }
|
|
260
|
+
}
|
|
261
|
+
paint(); load(false).then(scrollLog);
|
|
262
|
+
return { close: () => ui.close(), reload: () => load(true) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* ── the saved design: a brief, not a template ── */
|
|
266
|
+
export function openBrief({ ws, id, name } = {}) {
|
|
267
|
+
ensureDeckCss();
|
|
268
|
+
const body = el('div', { class: 'calbody brief rs rd-dk', 'data-rd-brief': id });
|
|
269
|
+
openSheet({ title: 'Report setup: ' + (name || id), icon: 'report', wide: true, cls: 'rd-wide', body });
|
|
270
|
+
put(body, el('div', { class: 'fold', role: 'status' }, icon('clock', 'sm'), ' Reading the design.'));
|
|
271
|
+
(async () => {
|
|
272
|
+
const r = await RD.design(ws, id);
|
|
273
|
+
if (!r || r.ok === false) { put(body, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), ' The design could not be read: ' + ((r && r.error) || 'no answer') + '.')); return; }
|
|
274
|
+
const D = r.design; const setup = RS.setups.find((x) => x.id === id) || r.setup || {};
|
|
275
|
+
const nm = setup.name || name || id; const out = formatOf(setup.output);
|
|
276
|
+
const lh = el('div', { class: 'lh' }, el('span', { class: 'ki' }, icon('report')), el('div', { class: 'rs-min0' }, el('b', null, nm),
|
|
277
|
+
el('div', { class: 'dim' }, (setup.label || scheduleLabel(setup.schedule)) + '. Output: ' + (setup.output === 'google-slides' ? 'a slide deck in PMOS. A Google Slides copy is optional.' : out.l + '.'))));
|
|
278
|
+
if (!D.saved) {
|
|
279
|
+
put(body, lh, sect({ ic: 'spark', title: 'Design', attrs: { 'data-rd-design': '' } }, el('div', { class: 'fold', 'data-rd-nosaved': '' }, icon('info', 'sm'), ' No saved design yet. Describe the report and refine it in the Design studio, then press This is it.'),
|
|
280
|
+
el('div', { class: 'ds-acts' }, pbtn('primary', 'Design how it looks', 'spark', { 'data-rd-act': 'studio', onclick: () => openStudio({ ws, id, name: nm }) }))));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const n = D.saved.version; let R = null; try { R = splitRender(await RD.version(ws, id, n)); } catch { R = null; }
|
|
284
|
+
const rule = (h, xs, attr) => (xs && xs.length ? el('div', { class: 'rd-dks-rule', 'data-rd-rule': attr }, el('b', null, h), el('ul', null, xs.map((x) => el('li', null, x)))) : null);
|
|
285
|
+
const ref = !R ? el('div', { class: 'fold w' }, icon('alert', 'sm'), ' The reference version could not be read.')
|
|
286
|
+
: R.format === 'deck' ? [el('div', { class: 'rd-dks-ref', 'data-rd-ref': String(n) }, R.slides.slice(0, 6).map((s) => el('div', { inert: '' }, slideCard(R, s, { cls: 'rd-thumb' })))), el('div', { class: 'dim rd-small' }, R.slides.length + ' slides with this cycle\'s data.' + (R.slides.length > 6 ? ' Showing the first 6.' : ''))]
|
|
287
|
+
: el('div', { class: 'rd-docf sm', 'data-rd-ref': String(n) }, frame(R.style, R.body, { title: 'The reference version', cls: 'pmos-doc', onLoad: (doc, fr) => fr.style.setProperty('--rp-h', Math.min(520, doc.documentElement.scrollHeight) + 'px') }));
|
|
288
|
+
const rules = D.rules || {};
|
|
289
|
+
const cycHost = el('ul', { class: 'chgl', 'data-rd-briefcycles': '' });
|
|
290
|
+
const docs = (RS.docs || []).filter((d) => d.setup === id).sort((a, b) => String(b.slot).localeCompare(String(a.slot)));
|
|
291
|
+
put(body, lh,
|
|
292
|
+
sect({ ic: 'spark', title: 'Design', right: el('span', { class: 'tag acc' }, 'Design D' + n), attrs: { 'data-rd-design': '' } },
|
|
293
|
+
el('div', { class: 'dim rd-small' }, 'Written by PMOS from your studio session. It is not a template: PMOS reads it each cycle and drafts to match it. Saved ' + dayAt(D.saved.at) + ', version D' + n + '.'),
|
|
294
|
+
el('h3', { class: 'ds-h' }, 'Your description'), el('blockquote', { class: 'rd-dks-q', 'data-rd-desc': '' }, D.description || 'No description.'),
|
|
295
|
+
el('h3', { class: 'ds-h' }, 'Your samples'), (D.samples || []).length ? D.samples.map(sampleChip) : el('div', { class: 'dim rd-small' }, 'No sample.'),
|
|
296
|
+
el('h3', { class: 'ds-h' }, 'Reference version · D' + n), ref,
|
|
297
|
+
el('h3', { class: 'ds-h' }, 'What PMOS learned'), rule('Keeps the same each cycle', rules.same, 'same'), rule('Changes each cycle', rules.changes, 'changes'), rule('Learned from your turns', rules.learned, 'learned'),
|
|
298
|
+
el('div', { class: 'ds-acts' }, pbtn('primary', 'Refine the design', 'spark', { 'data-rd-act': 'studio', onclick: () => openStudio({ ws, id, name: nm }) }), pbtn('outline', 'See the reference version', 'eye', { 'data-rd-act': 'refver', onclick: () => openStudio({ ws, id, name: nm, view: n }) }))),
|
|
299
|
+
sect({ ic: 'cal', title: 'Cycles drafted to match it', count: docs.length }, docs.length ? cycHost : el('div', { class: 'fold' }, icon('info', 'sm'), ' No cycle yet. The next one is drafted to match D' + n + '.')),
|
|
300
|
+
el('div', { class: 'fold' }, icon('shield', 'sm'), ' PMOS drafts each cycle. Nothing is sent: you present the deck or share the PDF yourself.'));
|
|
301
|
+
for (const d of docs) {
|
|
302
|
+
const c = await RD.cycle(ws, d.path);
|
|
303
|
+
const word = !c || c.ok === false || !c.designed ? el('span', { class: 'chip2' }, 'Before the design') : c.matches ? el('span', { class: 'chip2 ds-good' }, icon('check', 'sm'), 'Matches the design') : el('span', { class: 'chip2 ds-warn' }, icon('alert', 'sm'), (c.differences || []).length + ' difference' + ((c.differences || []).length === 1 ? '' : 's') + ' from the design');
|
|
304
|
+
cycHost.append(el('li', { 'data-rd-briefcycle': d.path }, icon('doc', 'sm'), el('span', null, slotWord(d.slot) + ' ', el('span', { class: 'dim rd-small' }, d.state === 'issued' ? 'Issued' : 'Draft')),
|
|
305
|
+
el('span', { class: 'chips2' }, word, pbtn('ghost', 'Open', 'out', { onclick: () => { closeSheet(); if (typeof RS.openDoc === 'function') RS.openDoc(d); } }))));
|
|
306
|
+
}
|
|
307
|
+
})();
|
|
308
|
+
}
|
|
@@ -17,9 +17,10 @@ export function ensureReportCss() {
|
|
|
17
17
|
document.head.appendChild(l);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** The five outputs of a recurring report. l: the name the page shows; ic: its icon; app: the tool a copy lives in; dl: the Download menu, [label, fmt].
|
|
20
|
+
/** The five outputs of a recurring report. l: the name the page shows; ic: its icon; app: the tool a copy lives in; dl: the Download menu, [label, fmt].
|
|
21
|
+
* The stored value 'google-slides' is kept for compatibility; the page names it "Slide deck": the deck lives in PMOS, a Google Slides copy is optional (report-design-studio). */
|
|
21
22
|
export const FORMATS = {
|
|
22
|
-
'google-slides': { l: '
|
|
23
|
+
'google-slides': { l: 'Slide deck', ic: 'img', app: 'Google Slides', copy: 'google-slides', word: 'slides', dl: [['PDF', 'pdf'], ['PowerPoint .pptx', 'pptx']] },
|
|
23
24
|
'google-doc': { l: 'Google Doc', ic: 'doc', app: 'Google Doc', copy: 'google', word: 'headings', dl: [['PDF', 'pdf'], ['Word .docx', 'docx']] },
|
|
24
25
|
'google-sheets': { l: 'Google Sheets', ic: 'sheet', app: 'Google Sheets', copy: 'google-sheets', word: 'sheets', dl: [['Excel .xlsx', 'xlsx'], ['CSV', 'csv']] },
|
|
25
26
|
confluence: { l: 'Confluence', ic: 'notes', app: 'Confluence', copy: 'confluence', word: 'headings', dl: [['PDF', 'pdf'], ['Word .docx', 'docx']] },
|
|
@@ -671,8 +671,24 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
671
671
|
return el('dl', { class: 'ds-props', 'data-doc-props': '' }, fm.fields.map((f) => [el('dt', null, f.key), el('dd', null, f.value)]),
|
|
672
672
|
el('div', { class: 'sub' }, c3ic('doc', 'sm'), 'Front matter', r ? [' · ', byChip(r)] : null));
|
|
673
673
|
}
|
|
674
|
+
/* a designed report cycle (initiative report-design-studio): c3-report-deck.js draws the deck or the interactive document in place of the
|
|
675
|
+
format view, and reaches back through this api (the Edit tab at a section, the Markdown source, the copies, the download, the edit log) */
|
|
676
|
+
const deckApi = { redraw: () => drawBg(), setSrc: (v) => { S.src = !!v; draw(); }, toggleCopies: () => toggleCopies(), download: (fmt) => download({ fmt }), editAt: (name) => editAt(name), logChanged: () => { S.log = null; }, go: (m) => go(m) };
|
|
677
|
+
function editAt(name) {
|
|
678
|
+
if (frozen()) { say('Issued reports are frozen. Start the next cycle to change the words.'); return; }
|
|
679
|
+
go('edit');
|
|
680
|
+
setTimeout(() => {
|
|
681
|
+
const k = S.ed ? S.ed.base.findIndex((x) => x.name === name) : -1;
|
|
682
|
+
const h = k >= 0 && ui ? ui.body.querySelector('[data-sid="' + k + '"]') : null;
|
|
683
|
+
if (!h) return;
|
|
684
|
+
h.scrollIntoView({ block: 'start' }); h.setAttribute('data-doc-editat', '');
|
|
685
|
+
try { const r = document.createRange(); r.selectNodeContents(h); r.collapse(false); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); const ed = ui.body.querySelector('[data-doc-editor]'); if (ed) ed.focus({ preventScroll: true }); } catch { /* the caret stays where it is */ }
|
|
686
|
+
}, 30);
|
|
687
|
+
}
|
|
674
688
|
function readBody() {
|
|
675
689
|
if (S.text == null) return S.err ? el('div', { class: 'fold w', role: 'alert' }, c3ic('alert', 'sm'), S.err) : el('div', { class: 'fold', role: 'status' }, c3ic('clock', 'sm'), 'Reading the document.');
|
|
690
|
+
const deck = R && R.deck && !S.src ? R.deck(deckApi) : null;
|
|
691
|
+
if (deck) return el('div', { class: 'ds' }, staleBanner(), deck);
|
|
676
692
|
const secs = splitDoc(S.text);
|
|
677
693
|
if (!S.text.trim()) return el('div', { class: 'fold' }, c3ic('moon', 'sm'), 'The document is empty.');
|
|
678
694
|
const items = R ? outlineLabels(secs, R.output) : outlineOf(secs); const olOn = items.length >= 2 && !S.src;
|
|
@@ -713,7 +729,8 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
713
729
|
const cells = R && !S.src ? [formatView({ output: R.output, name: R.name || doc.name, cycle: R.cycle, foot: R.foot, secs, md: (t) => md(t, 'ds-body'), wrap: (s, inner, label) => secCell(s, inner, label) })] : secs.map((s) => secCell(s));
|
|
714
730
|
const adds = openProps().flatMap((p) => p.sections.filter((c) => c.kind === 'added' && S.preview[p.id]).map((c) => el('div', { class: 'ds-sec a prev', 'data-dsec-new': c.name },
|
|
715
731
|
el('div', { class: 'ds-pin' }, el('span', null, c3ic('spark', 'sm'), 'The proposal adds a section: ' + c.name)), el('div', { class: 'ds-sec-h' }, el('h2', null, c.name)), md(diffMd(null, c.after), 'ds-body'))));
|
|
716
|
-
const
|
|
732
|
+
const copyNote = R && R.copyNotice ? el('div', { class: 'fold', 'data-rd-copynote': '' }, c3ic('info', 'sm'), ' The deck now lives in PMOS. The Google Slides copy of this report is optional. ', btn('chip2 lk', {}, 'Copies in other tools', () => toggleCopies())) : null;
|
|
733
|
+
const main = [generated ? el('div', { class: 'fold', role: 'note', 'data-doc-generated': '' }, c3ic('info', 'sm'), generated.why) : null, copyNote, staleBanner(), propsBlock(secs, false), cells, adds];
|
|
717
734
|
return el('div', { class: 'ds' + (S.who ? ' who' : '') }, bar, legend,
|
|
718
735
|
olOn ? el('div', { class: 'ds-rlay' + (S.ol ? '' : ' nool') + (S.olOpen ? ' olopen' : '') }, outlineNav(items), el('div', { class: 'ds-rmain' }, main)) : el('div', { class: 'ds-rmain' }, main));
|
|
719
736
|
}
|
|
@@ -962,6 +979,13 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
962
979
|
}
|
|
963
980
|
|
|
964
981
|
/* ── Edit log ── */
|
|
982
|
+
/* notes on the slide and the cycle's design decisions (report-design-studio): the server appends them to the document's log */
|
|
983
|
+
const DESIGN_T = { keep: 'Kept a difference from the design for this cycle', adopt: 'Made a difference part of the design', match: 'Matched the design' };
|
|
984
|
+
function pinTitle(e) {
|
|
985
|
+
const on = e.slide ? ' (' + e.slide + ')' : '';
|
|
986
|
+
if (e.op === 'add') return (e.by === 'agent' ? 'PMOS proposed a note on the slide' : 'Note pinned on the slide') + on;
|
|
987
|
+
return ({ edit: 'Changed a note on the slide', remove: 'Removed a note on the slide', keep: 'Kept the note PMOS proposed', dismiss: 'Dismissed the note PMOS proposed' }[e.op] || 'Note on the slide') + on;
|
|
988
|
+
}
|
|
965
989
|
function logEntries() {
|
|
966
990
|
const L = S.log || { revisions: [], events: [] };
|
|
967
991
|
const out = [];
|
|
@@ -971,7 +995,8 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
971
995
|
out.push({ kind: 'rev', at: r.at, r, ag, outside, people: !ag, title: (r.v ? r.v + ' · ' : 'Revision ' + r.rev + ' · ') + title });
|
|
972
996
|
}
|
|
973
997
|
for (const e of L.events) {
|
|
974
|
-
const t =
|
|
998
|
+
const t = e.kind === 'pin' ? pinTitle(e) : e.kind === 'design' ? DESIGN_T[e.choice] || 'A decision on the design'
|
|
999
|
+
: { proposed: 'The agent drafted a proposal', revised: 'The agent revised a proposal', rejected: 'Proposal rejected', asked: 'You asked the agent', 'ask-failed': 'The agent did not draft the change', marked: 'Marked as still current' }[e.kind] || e.kind;
|
|
975
1000
|
out.push({ kind: e.kind, at: e.at, e, ag: e.by === 'agent' || e.kind === 'proposed' || e.kind === 'revised' || e.kind === 'ask-failed', outside: false, people: e.by === 'pm' || e.kind === 'rejected' || e.kind === 'asked' || e.kind === 'marked', title: t });
|
|
976
1001
|
}
|
|
977
1002
|
return out.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
@@ -995,7 +1020,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
995
1020
|
else if (noReason) why = el('p', { class: 'why none' }, c3ic('alert', 'sm'), ' No reason recorded: this change was made outside PMOS. ', btn('chip2 lk ds-pe', { 'data-doc-whyadd': String(r.rev) }, 'Add a reason', () => { S.whyFor = r.rev; S.whyText = ''; draw(); const i = ui.body.querySelector('#daddwhy'); if (i) i.focus(); }));
|
|
996
1021
|
else if (r.why) why = el('p', { class: 'why' }, r.why);
|
|
997
1022
|
} else {
|
|
998
|
-
const t = e.kind === 'asked' ? '“' + e.prompt + '”' : e.kind === 'proposed' || e.kind === 'revised' ? 'For: “' + (e.prompt || '') + '”' : e.why || '';
|
|
1023
|
+
const t = e.kind === 'pin' ? (e.text ? '“' + e.text + '” Note on the slide: the words on the slide did not change.' : 'Note on the slide.') : e.kind === 'design' ? (e.what || e.diff || '') : e.kind === 'asked' ? '“' + e.prompt + '”' : e.kind === 'proposed' || e.kind === 'revised' ? 'For: “' + (e.prompt || '') + '”' : e.why || '';
|
|
999
1024
|
if (t) why = el('p', { class: 'why' }, t);
|
|
1000
1025
|
}
|
|
1001
1026
|
const tags = [];
|
|
@@ -1103,7 +1128,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
1103
1128
|
function nextCycle() { S.menu = false; if (R.onNext) R.onNext(doc, ui); else say('The next cycle starts on its schedule. PMOS drafts it then.'); }
|
|
1104
1129
|
function drawTools() {
|
|
1105
1130
|
if (!toolsNode) return;
|
|
1106
|
-
toolsNode.replaceChildren(...[R.state === 'draft' ? pb('outline', { 'data-doc-issue': '' }, [c3ic('check', 'sm'), 'Issue'], () => issue()) : null,
|
|
1131
|
+
toolsNode.replaceChildren(...[...(R.tools ? R.tools() : []), R.state === 'draft' ? pb('outline', { 'data-doc-issue': '' }, [c3ic('check', 'sm'), 'Issue'], () => issue()) : null,
|
|
1107
1132
|
btn('ib sm', { 'aria-label': 'Download and more', title: 'Download and more', 'aria-expanded': String(S.menu), 'data-doc-more': '' }, c3ic('more', 'sm'), () => { S.menu = !S.menu; draw(); })].filter(Boolean));
|
|
1108
1133
|
}
|
|
1109
1134
|
|
|
@@ -1122,7 +1147,7 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
1122
1147
|
const had = keep ? { id: keep.id, s: keep.selectionStart } : null;
|
|
1123
1148
|
const above = docAbove.map((fn) => { try { return fn({ ws, doc, text: S.text, key: S }); } catch { return null; } });
|
|
1124
1149
|
ui.body.replaceChildren(el('div', { class: 'dpn ds' }, top(), above, copiesPanel(), body));
|
|
1125
|
-
const sh = ui.body.closest('.sheet'); if (sh) sh.classList.toggle('dwx', S.mode === 'read' && S.ol && !!ui.body.querySelector('.ds-ol'));
|
|
1150
|
+
const sh = ui.body.closest('.sheet'); if (sh) sh.classList.toggle('dwx', S.mode === 'read' && ((S.ol && !!ui.body.querySelector('.ds-ol')) || !!ui.body.querySelector('[data-rd-deck]')));
|
|
1126
1151
|
const sb = ui.body.closest('.sb');
|
|
1127
1152
|
if (sb && !sb.__dsTrack) { sb.__dsTrack = true; sb.addEventListener('scroll', trackCur, { passive: true }); }
|
|
1128
1153
|
if (S.mode === 'read') { placeOl(); trackCur(); }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* pm/c3-ib-items.js — the Backlog, Board and Timeline tabs of a board (mockup v5, 34-initiatives.js tabItems / backlogBody / kanbanBody /
|
|
2
2
|
* timelineBody). The three show the same work items, Jira items and PMOS tasks mixed (GET /api/boards/items through loadItems), under ONE
|
|
3
|
-
* filter set per board that the three tabs share: search,
|
|
3
|
+
* filter set per board that the three tabs share, on one row (c3-board-declutter): search, an initiative picker, and a Filters panel
|
|
4
|
+
* with Type, Source and Done; a filter that is on shows as a chip with an x. ctx.filter.ini (a link "this initiative
|
|
4
5
|
* only" from an initiative page) sets the Initiative filter on mount. Source shows only when the board has Build and deliver on: with it
|
|
5
6
|
* off there is no Jira, and every item is a PMOS task.
|
|
6
7
|
* registerBoardTab('backlog' | 'board' | 'timeline', (host, ctx) => ...)
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
* Every control is a button; the drag handlers are drag events on the cards and columns (pointer handlers stay on the document). */
|
|
15
16
|
import { el } from './dom.js';
|
|
16
17
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
17
|
-
import { icon, pill, lk, ib, pbtn, note,
|
|
18
|
+
import { icon, pill, lk, ib, pbtn, note, sect, card, list, empty, stepsMini, jiraLabel, plural, trunc, openMenu, mono, put } from './c3-ini-ui.js';
|
|
18
19
|
import { STATUSES, wfOf, tplName, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
19
20
|
import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
|
|
20
21
|
|
|
@@ -52,25 +53,33 @@ function mountItems(tab) {
|
|
|
52
53
|
const withWork = [...new Set(all.map((w) => w.ini))].map((id) => byId.get(id)).filter(Boolean).sort((a, c) => ((c.featured ? 1 : 0) - (a.featured ? 1 : 0)) || a.id.localeCompare(c.id));
|
|
53
54
|
if (st.ini && !withWork.some((i) => i.id === st.ini) && byId.get(st.ini)) withWork.push(byId.get(st.ini));
|
|
54
55
|
const others = R.initiatives.length - withWork.length;
|
|
55
|
-
const n = shown.filter((w) => w.kind !== 'Epic').length; const ne = shown.filter((w) => w.kind === 'Epic').length;
|
|
56
56
|
const search = el('input', { type: 'text', value: st.q, placeholder: 'Search ' + b.name + ' work items', 'aria-label': 'Search work items', 'data-wk': 'q', autocomplete: 'off' });
|
|
57
57
|
let deb = null;
|
|
58
58
|
search.addEventListener('input', () => { clearTimeout(deb); deb = setTimeout(() => { st.q = search.value; draw('[data-wk="q"]'); }, 200); });
|
|
59
|
-
const
|
|
59
|
+
const tick = () => el('span', { class: 'il-tick' }, icon('check', 'sm'));
|
|
60
|
+
const SRCS = [['', 'All sources'], ['jira', 'Jira ' + jiraLabel(b)], ['pmos', 'PMOS tasks']];
|
|
61
|
+
const nf = (st.kind ? 1 : 0) + (st.src ? 1 : 0) + (st.hideDone ? 1 : 0);
|
|
62
|
+
const iniMenu = (anchor) => openMenu(anchor, { label: 'Initiative', items: [{ group: 'Initiative' },
|
|
63
|
+
{ kids: [tick(), 'All initiatives'], checked: !st.ini, attrs: { 'data-wk-ini': '' }, onPick: () => { st.ini = ''; draw('[data-wk="ini"]'); } },
|
|
64
|
+
...withWork.map((i) => ({ kids: [tick(), el('span', { class: 'mono' }, i.id), ' ' + trunc(i.title, 30)], checked: st.ini === i.id, attrs: { 'data-wk-ini': i.id, title: i.id + ' ' + i.title }, onPick: () => { st.ini = i.id; draw('[data-wk="ini"]'); } })),
|
|
65
|
+
...(others > 0 ? [{ group: plural(others, 'more has', 'more have') + ' no work yet' }] : [])] });
|
|
66
|
+
const filterMenu = (anchor) => {
|
|
67
|
+
const sel = (k, label, opts) => el('label', { class: 'il-f' }, el('span', null, label), el('select', { 'aria-label': label, 'data-wk': k === 'kind' ? 'kind' : 'src', onchange: (e) => { st[k] = e.target.value; draw('[data-wk="filters"]'); } }, ...opts.map(([v, l]) => el('option', { value: v, selected: v === st[k] }, l))));
|
|
68
|
+
openMenu(anchor, { label: 'Filters', body: el('div', { 'data-wk': 'filter-panel' }, el('div', { class: 'ini-mlbl' }, 'Filter work items'),
|
|
69
|
+
sel('kind', 'Type', KINDS), b.build ? sel('src', 'Source', SRCS) : null,
|
|
70
|
+
el('label', { class: 'il-f' }, el('span', null, 'Done'), el('select', { 'aria-label': 'Done items', 'data-wk': 'done', onchange: (e) => { st.hideDone = e.target.value === 'hide'; draw('[data-wk="filters"]'); } }, el('option', { value: 'show', selected: !st.hideDone }, 'Show'), el('option', { value: 'hide', selected: st.hideDone }, 'Hide'))),
|
|
71
|
+
b.build ? null : el('div', { class: 'dim il-fnote', 'data-wk': 'no-jira' }, 'Build and deliver is off, so every item is a PMOS task. ', lk({ onclick: () => ctx.openBoardSettings() }, 'Board settings')),
|
|
72
|
+
el('div', { class: 'chips2 il-fend' }, lk({ 'data-wk': 'clear-in-menu', onclick: () => { Object.assign(st, { q: '', ini: '', src: '', kind: '', hideDone: false }); draw(); } }, 'Clear filters'))) });
|
|
73
|
+
};
|
|
74
|
+
const on = [st.kind ? ['kind', 'Type: ' + st.kind] : null, st.src ? ['src', 'Source: ' + (st.src === 'jira' ? 'Jira' : 'PMOS tasks')] : null, st.hideDone ? ['hideDone', 'Done hidden'] : null].filter(Boolean);
|
|
75
|
+
const any = st.q || st.ini || on.length;
|
|
60
76
|
return [
|
|
61
|
-
el('div', { class: 'calbar' }, el('div', { class: 'form
|
|
62
|
-
el('
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
others > 0 ? el('span', { class: 'dim ini-note' }, plural(others, 'more initiative has', 'more initiatives have') + ' no epics or tasks yet') : null),
|
|
68
|
-
b.build
|
|
69
|
-
? el('div', { class: 'chips2', role: 'group', 'aria-label': 'Source filter', 'data-wk': 'srcs' }, lbl('SOURCE'),
|
|
70
|
-
...[['', 'All sources'], ['jira', [icon('jira', 'sm'), 'Jira ' + jiraLabel(b)]], ['pmos', 'PMOS tasks']].map(([v, l]) => pill({ 'data-wk-src': v, 'aria-pressed': String(st.src === v), onclick: () => { st.src = v; draw('[data-wk-src="' + v + '"]'); } }, ...[].concat(l))))
|
|
71
|
-
: el('div', { class: 'dim ini-note', 'data-wk': 'no-jira' }, icon('info', 'sm'), ' Build and deliver is off for ' + b.name + ', so there is no Jira here: every item is a PMOS task. ', lk({ onclick: () => ctx.openBoardSettings() }, 'Board settings')),
|
|
72
|
-
el('div', { class: 'dim ini-note', 'data-wk': 'count' }, plural(n, 'item') + (ne ? ' in ' + plural(ne, 'epic') : '') + (st.ini ? ' · ' + st.ini + ' only' : '') + (st.src ? ' · ' + (st.src === 'jira' ? 'Jira only' : 'PMOS only') : '') + ' · ',
|
|
73
|
-
any ? lk({ 'data-wk': 'clear', onclick: () => { Object.assign(st, { q: '', ini: '', src: '', kind: '', hideDone: false }); draw(); } }, 'Clear filters') : null, any ? ' · ' : null, 'same filters on Backlog, Board and Timeline'),
|
|
77
|
+
el('div', { class: 'calbar wk-fbar' }, el('div', { class: 'form il-search' }, icon('search', 'sm'), search),
|
|
78
|
+
pbtn('ghost', [st.ini ? el('span', { class: 'mono' }, st.ini) : 'All initiatives', el('span', { class: 'il-chev' }, icon('next', 'sm'))], null, { 'data-wk': 'ini', 'aria-haspopup': 'menu', 'aria-expanded': 'false', title: 'Initiative', onclick: (e) => iniMenu(e.currentTarget) }),
|
|
79
|
+
ib(nf ? 'Filters, ' + nf + ' on' : 'Filters', [icon('filter', 'sm'), nf ? el('span', { class: 'bdg' }, String(nf)) : null], { class: 'il-ib', 'data-wk': 'filters', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', title: 'Filters', onclick: (e) => filterMenu(e.currentTarget) }),
|
|
80
|
+
any ? el('div', { class: 'chips2 il-chips', 'data-wk': 'chips' },
|
|
81
|
+
...on.map(([k, l]) => lk({ 'data-wk-unset': k, title: 'Remove this filter', onclick: () => { st[k] = k === 'hideDone' ? false : ''; draw('[data-wk="filters"]'); } }, l + ' ', icon('x', 'sm'))),
|
|
82
|
+
lk({ 'data-wk': 'clear', onclick: () => { Object.assign(st, { q: '', ini: '', src: '', kind: '', hideDone: false }); draw(); } }, 'Clear')) : null),
|
|
74
83
|
];
|
|
75
84
|
}
|
|
76
85
|
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* and `status` when a release carries them) and keeps the releases whose initiative_ids meet the board's initiatives (loadItems). A
|
|
5
5
|
* release that names another board in `board` is left out. Read-only: releases mirror Jira fix versions; their dates live on the Calendar.
|
|
6
6
|
* Left: Unreleased (the nearest first), each row with its status, date and days to go, the share of its scope done, and in place its
|
|
7
|
-
* scope as links; then Released.
|
|
7
|
+
* scope as links; then Released. The Unreleased header links to the Calendar (c3-board-declutter: no side card). Nothing is invented: a release with no name shows
|
|
8
8
|
* its id, one with no date says "No date yet". */
|
|
9
9
|
import { el } from './dom.js';
|
|
10
10
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
11
|
-
import { icon,
|
|
11
|
+
import { icon, lk, chip, tag, st, note, sect, list, empty, nrow, hbs, hbr, statusChip, jiraLabel, plural, day, daysBetween, put } from './c3-ini-ui.js';
|
|
12
12
|
import { loadItems } from './c3-ini-work.js';
|
|
13
13
|
|
|
14
14
|
const whenOf = (r) => String(r.release_at || r.deploy_at || r.date || '').slice(0, 10);
|
|
@@ -42,10 +42,7 @@ registerBoardTab('releases', async (host, ctx) => {
|
|
|
42
42
|
: [el('span', { class: 'dim' }, 'No initiatives in scope')]))],
|
|
43
43
|
attrs: { 'data-inb-release': r.id, 'data-inb-when': isPast ? 'past' : r === up[0] ? 'next' : 'later' } });
|
|
44
44
|
};
|
|
45
|
-
put(host,
|
|
46
|
-
|
|
47
|
-
past.length ? sect({ ic: 'check', title: 'Released', count: past.length, attrs: { 'data-inb': 'release-past' } }, list(past.map((r) => row(r, true)))) : null],
|
|
48
|
-
[card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + jiraLabel(b) + ' fix versions, because this board builds and delivers. Release dates are references: their home is the Calendar.'),
|
|
49
|
-
el('div', { class: 'chips2' }, pbtn('outline', 'Open the Calendar', 'cal', { 'data-inb': 'open-calendar', onclick: () => ctx.showTab('cal') })))]));
|
|
45
|
+
put(host, ...[sect({ ic: 'out', title: 'Unreleased', count: up.length, right: lk({ 'data-inb': 'open-calendar', title: 'Releases mirror Jira ' + jiraLabel(b) + ' fix versions. Their dates live on the Calendar.', onclick: () => ctx.showTab('cal') }, icon('cal', 'sm'), 'On the Calendar'), attrs: { 'data-inb': 'release-next' } }, up.length ? list(up.map((r) => row(r, false))) : empty('No release planned for ' + b.name + '.', { 'data-inb': 'release-none-next' })),
|
|
46
|
+
past.length ? sect({ ic: 'check', title: 'Released', count: past.length, attrs: { 'data-inb': 'release-past' } }, list(past.map((r) => row(r, true)))) : null].filter(Boolean));
|
|
50
47
|
return () => { dead = true; };
|
|
51
48
|
});
|