create-pmos 0.179.0 → 0.180.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-setup-test.js +190 -0
- package/workspace/c3-reports-test.js +30 -97
- package/workspace/guide/document-sheet.md +14 -0
- package/workspace/guide/index.md +3 -2
- package/workspace/guide/reports-dashboards.md +17 -28
- package/workspace/guide/reports-page.md +10 -5
- package/workspace/guide/reports-set-up.md +56 -0
- package/workspace/guide/reports.md +13 -1
- package/workspace/guide/settings.md +11 -2
- package/workspace/lib/doc-sync.js +28 -6
- package/workspace/lib/docs-cli.js +7 -11
- package/workspace/lib/four-panel.js +1 -1
- package/workspace/lib/headless.js +21 -2
- package/workspace/lib/report-checks.js +103 -0
- package/workspace/lib/report-cli.js +89 -4
- package/workspace/lib/report-export.js +347 -0
- package/workspace/lib/report-finder.js +156 -0
- package/workspace/lib/report-needs.js +1 -1
- package/workspace/lib/report-setups.js +743 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-c3-reports.js +35 -17
- package/workspace/lib/routes-doc-health.js +15 -1
- package/workspace/lib/routes-report-setups.js +368 -0
- package/workspace/lib/schedule-logic.js +5 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-rep2.js +15 -0
- package/workspace/public/pm/c3-demo-rs.js +281 -0
- package/workspace/public/pm/c3-demo.js +1 -1
- package/workspace/public/pm/c3-doc-formats.js +152 -0
- package/workspace/public/pm/c3-doc-sheet.js +115 -25
- package/workspace/public/pm/c3-quiet.js +2 -2
- package/workspace/public/pm/c3-report-print.css +20 -0
- package/workspace/public/pm/c3-report-setup.css +62 -0
- package/workspace/public/pm/c3-report-setup.js +260 -0
- package/workspace/public/pm/c3-report-store.js +208 -0
- package/workspace/public/pm/c3-reports.css +4 -0
- package/workspace/public/pm/c3-reports.js +47 -181
- package/workspace/public/pm/c3-settings.js +4 -3
- package/workspace/public/pm/c3-shell.js +15 -31
- package/workspace/server.js +66 -5
- package/workspace/skills/doc-sync.skill +17 -8
- package/workspace/skills/report-find.skill +75 -0
- package/workspace/skills/report-setup.skill +67 -0
- package/workspace/test-files.json +10 -0
- package/workspace/test-lists/report-workspace-ui.json +5 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/* pm/c3-report-setup.js — the REPORTS tab of the console3 Reports page (initiative report-workspace; mockup 41-reportsetup.js, hashes #rs, #rs-ask, #rs-proposal, #rs-cycles,
|
|
2
|
+
* #reports-documents, #dash-send). Top to bottom: the rule line, Ask PMOS, Found in your work, Set up, Documents.
|
|
3
|
+
*
|
|
4
|
+
* mountReportSetup(host, ctx) -> { dispose, refresh } ctx = { ws }
|
|
5
|
+
* openSendSheet({ ws, key, title, charts, onDone }) "Send this as a report…" from a dashboard: a prefilled set-up sheet
|
|
6
|
+
* openReportDoc(doc, mode) a report cycle in the document sheet (c3-doc-sheet.js, report option)
|
|
7
|
+
* openFindingSheet(id, change) the proposal of one finding, as a sheet
|
|
8
|
+
*
|
|
9
|
+
* Reads and presses are c3-report-store.js. Nothing here sends anything: Set up, Create the copy, Update the copy and Download happen only on a press.
|
|
10
|
+
* A report is a document: Open on a row opens its markdown file in the document sheet, in the format of its output. */
|
|
11
|
+
import { el } from './dom.js';
|
|
12
|
+
import { icon, pbtn, chip, tag, sect, put, list, empty, lk, btn } from './c3-ini-ui.js';
|
|
13
|
+
import { openSheet, closeSheet } from './c3-sheet.js';
|
|
14
|
+
import { openDocSheet } from './c3-doc-sheet.js';
|
|
15
|
+
import { toast } from './c3-toast.js';
|
|
16
|
+
import { FORMATS, OUTPUTS, formatOf, ensureReportCss } from './c3-doc-formats.js';
|
|
17
|
+
import { RS, onReports, loadReports, askPmos, changeIt, cancelAsk, setupFrom, dismissLater, pauseResume, fromDashboard, goTo, evidenceKind, normProposal, scheduleLabel, nextDraftDate, dayText, docsOf, outChip, copyChip, srcChip } from './c3-report-store.js';
|
|
18
|
+
|
|
19
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
20
|
+
/** 'Wed 21 Oct 2026' for a day, 'Oct 2026' for a month, any other slot as it is. */
|
|
21
|
+
export function slotText(slot) {
|
|
22
|
+
const s = String(slot || '');
|
|
23
|
+
let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); if (m) return dayText(s) + ' ' + m[1];
|
|
24
|
+
m = /^(\d{4})-(\d{2})$/.exec(s); if (m) return MON[+m[2] - 1] + ' ' + m[1];
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
const vText = (v) => (v == null || v === '' ? '' : /^\d+$/.test(String(v)) ? 'v' + v : String(v));
|
|
28
|
+
const lbl = (t) => el('div', { class: 'dim mono rs-lbl' }, t);
|
|
29
|
+
const help = (...k) => el('div', { class: 'dim rs-help' }, ...k);
|
|
30
|
+
const li = (ic, kids, right) => el('li', null, icon(ic, 'sm'), el('span', null, ...[].concat(kids)), right ? el('span', { class: 'dim' }, right) : el('span'));
|
|
31
|
+
const kv = (pairs) => el('dl', { class: 'rs-kv' }, pairs.filter(Boolean).map(([k, v]) => [el('dt', null, k), el('dd', null, ...[].concat(v))]));
|
|
32
|
+
const stag = (d) => el('span', { class: 'tag ' + (d.state === 'issued' ? 'ok' : 'acc'), 'data-rs-state': d.state }, d.state === 'issued' ? 'Issued' : 'Draft');
|
|
33
|
+
|
|
34
|
+
/* ── one proposal, drawn the same in the sheet and in the Ask card ── */
|
|
35
|
+
const view = (key, P) => { const notes = (RS.chg[key] && RS.chg[key].notes) || []; return { ...P, output: (RS.edit[key] && RS.edit[key].output) || P.output, ...(notes.length ? { notes } : {}) }; };
|
|
36
|
+
function foundList(P) {
|
|
37
|
+
return [P.found.length ? el('ul', { class: 'chgl' }, P.found.map((e) => { const [ic, nm] = evidenceKind(e.kind); return li(ic, [el('b', null, nm), ' ' + e.text], e.when); }))
|
|
38
|
+
: el('div', { class: 'fold' }, icon('info', 'sm'), ' PMOS found no earlier copy or event for this. It works from your words.'),
|
|
39
|
+
el('div', { class: 'fold' }, icon('shield', 'sm'), ' PMOS read mail subjects and recipient counts only. It did not open any message body.')];
|
|
40
|
+
}
|
|
41
|
+
function outSeg(key, P, redo) {
|
|
42
|
+
return [el('div', { class: 'seg', role: 'group', 'aria-label': 'Output' }, OUTPUTS.map((k) => btn({ 'data-rs-act': 'out', 'data-v': k, 'aria-pressed': String(P.output === k),
|
|
43
|
+
onclick: () => { RS.edit[key] = { ...(RS.edit[key] || {}), output: k }; redo(); } }, FORMATS[k].l))),
|
|
44
|
+
help('PMOS shows it in the same format here. It writes to Google or Confluence only when you press Update the copy.')];
|
|
45
|
+
}
|
|
46
|
+
function proposed(key, P, redo) {
|
|
47
|
+
const sch = P.schedule && typeof P.schedule === 'object' ? P.schedule : null; const first = sch ? nextDraftDate(sch) : null;
|
|
48
|
+
const lines = (RS.chg[key] && RS.chg[key].notes) || [];
|
|
49
|
+
return kv([['Name', P.name || ''], ['Purpose', P.purpose || ''], ['Audience', P.audience || ''], ['Output', outSeg(key, P, redo)],
|
|
50
|
+
['Schedule', [P.label || scheduleLabel(sch), first ? el('span', { class: 'dim' }, ' First draft ' + dayText(first)) : sch && !sch.day && !sch.dayOfMonth ? el('span', { class: 'dim' }, ' First draft not set yet') : null]],
|
|
51
|
+
['Sources it will read', P.sources.join(', ') || 'Your earlier copies'],
|
|
52
|
+
['Sections in order', P.sections.length ? el('ol', null, P.sections.map((s) => el('li', null, typeof s === 'string' ? s : s.name || s.title || ''))) : 'PMOS learns them from your earlier copies'],
|
|
53
|
+
lines.length ? ['Your changes', lines.join('. ')] : null]);
|
|
54
|
+
}
|
|
55
|
+
const learnedBlock = (P) => (P.learned ? [el('div', { class: 'chips' }, chip(icon('doc', 'sm'), P.learned.name)), P.learned.why ? help(P.learned.why) : null] : help('PMOS found no earlier copy to learn from.'));
|
|
56
|
+
const fixesList = (P) => (P.fixes.length ? el('ul', { class: 'chgl' }, P.fixes.map((f) => li('check', [String(f)]))) : help('No fix is needed.'));
|
|
57
|
+
function changeBox(key, id, redo) {
|
|
58
|
+
const st = RS.chg[key] || {};
|
|
59
|
+
const said = st.said ? el('div', { class: 'chips' }, chip(icon('user', 'sm'), 'You: ' + st.said)) : null;
|
|
60
|
+
if (!st.open) return said;
|
|
61
|
+
const inp = el('input', { type: 'text', 'data-rs-reply': key, placeholder: 'Tell PMOS what to change, for example: draft it on Tuesday instead', 'aria-label': 'Tell PMOS what to change', autocomplete: 'off',
|
|
62
|
+
onkeydown: (e) => { if (e.key === 'Enter') go.click(); } });
|
|
63
|
+
const go = pbtn('primary', 'Update', 'reply', { 'data-rs-act': 'send', onclick: async () => {
|
|
64
|
+
const v = inp.value.trim(); if (!v) { inp.focus(); return; }
|
|
65
|
+
/* the change call takes an ask id; a finding has none, so its change is kept as a note that goes with the proposal at Set up */
|
|
66
|
+
const isAsk = RS.asks.some((x) => x.id === id);
|
|
67
|
+
const r = isAsk ? await changeIt(id, v) : { ok: true }; if (!r || !r.ok) return;
|
|
68
|
+
RS.chg[key] = { open: false, said: v, changed: true, notes: isAsk ? [] : [...((RS.chg[key] && RS.chg[key].notes) || []), v] };
|
|
69
|
+
toast(isAsk ? 'Sent. PMOS revises the proposal. Set it up when it reads right.' : 'Noted. It goes with the proposal when you set it up.'); redo(); } });
|
|
70
|
+
return [said, el('div', { class: 'form' }, inp, go, pbtn('ghost', 'Cancel', null, { 'data-rs-act': 'cancel-change', onclick: () => { RS.chg[key] = { ...st, open: false }; redo(); } }))];
|
|
71
|
+
}
|
|
72
|
+
const confTag = (c) => el('span', { class: 'tag ' + (c === 'not-sure' ? 'warn' : 'acc'), 'data-rs-conf': c || '' }, c === 'not-sure' ? 'Not sure' : c === 'likely' ? 'Likely' : 'Matched');
|
|
73
|
+
|
|
74
|
+
/* ── a finding, as a sheet ── */
|
|
75
|
+
export function openFindingSheet(id, change) {
|
|
76
|
+
const f0 = RS.findings.find((x) => x.id === id); if (!f0) return;
|
|
77
|
+
if (change) RS.chg[id] = { ...(RS.chg[id] || {}), open: true };
|
|
78
|
+
const body = el('div', { class: 'rs' });
|
|
79
|
+
const paint = () => {
|
|
80
|
+
const f = RS.findings.find((x) => x.id === id); if (!f || RS.hidden.has(id)) { closeSheet(); return; }
|
|
81
|
+
const P = view(id, normProposal(f.proposal)); const st = RS.chg[id] || {}; const cb = changeBox(id, id, paint);
|
|
82
|
+
put(body, el('div', { class: 'calbody brief rs', 'data-rs-sheet': id },
|
|
83
|
+
el('div', { class: 'lh' }, el('span', { class: 'ki' }, icon('report')), el('div', { class: 'rs-min0' }, el('b', null, f.name), el('div', { class: 'dim' }, f.why || '')),
|
|
84
|
+
el('span', { class: 'chips2' }, confTag(f.confidence), st.changed ? el('span', { class: 'tag acc' }, 'Changed') : null)),
|
|
85
|
+
sect({ ic: 'search', title: 'What PMOS found', count: P.found.length }, ...foundList(P)),
|
|
86
|
+
sect({ ic: 'report', title: 'Proposed report' }, proposed(id, P, paint)),
|
|
87
|
+
sect({ ic: 'doc', title: 'Learned from' }, ...[].concat(learnedBlock(P))),
|
|
88
|
+
sect({ ic: 'check', title: 'Fixes it will apply', count: P.fixes.length }, fixesList(P)),
|
|
89
|
+
cb ? sect({ ic: 'edit', title: 'Your change' }, ...[].concat(cb)) : null,
|
|
90
|
+
el('div', { class: 'chips2' }, pbtn('primary', 'Set up', 'check', { 'data-rs-act': 'setup', onclick: async () => { const r = await setupFrom({ finding: id, proposal: P }); if (r && r.ok) closeSheet(); } }),
|
|
91
|
+
pbtn('outline', 'Change first', 'edit', { 'data-rs-act': 'change', onclick: () => { RS.chg[id] = { ...(RS.chg[id] || {}), open: true }; paint(); } }),
|
|
92
|
+
pbtn('ghost', 'Not a report', 'x', { 'data-rs-act': 'dismiss', onclick: () => { closeSheet(); dismissLater(id); } })),
|
|
93
|
+
help('PMOS reads your past copies to learn the sections. It drafts only; you issue.')));
|
|
94
|
+
};
|
|
95
|
+
const off = onReports(paint);
|
|
96
|
+
openSheet({ title: 'Report found: ' + f0.name, icon: 'report', wide: true, body, onClose: off });
|
|
97
|
+
paint();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ── Ask PMOS ── */
|
|
101
|
+
function askCard(a, redo) {
|
|
102
|
+
const key = a.id; const st = RS.chg[key] || {};
|
|
103
|
+
const head = el('div', { class: 'chips' }, chip(icon('user', 'sm'), 'You: ' + a.text), a.changed || st.changed ? el('span', { class: 'tag acc', 'data-rs-changed': '' }, 'Changed') : null);
|
|
104
|
+
const attrs = { 'data-rs-card': a.id, 'data-rs-ask-state': a.state, 'aria-label': 'PMOS answer' };
|
|
105
|
+
if (a.state === 'running') {
|
|
106
|
+
return sect({ ic: 'spark', title: 'PMOS answer', attrs, right: el('span', { class: 'tag' }, 'Working') }, el('div', { class: 'rs-stack' }, head,
|
|
107
|
+
el('div', { class: 'fold', role: 'status', 'data-rs-running': '' }, icon('clock', 'sm'), ' PMOS is reading your earlier copies and the report definitions. This takes a minute. You can leave this page.'),
|
|
108
|
+
el('div', { class: 'chips2' }, pbtn('ghost', 'Cancel', 'x', { 'data-rs-act': 'cancel-ask', onclick: async () => { const r = await cancelAsk(a.id); if (r && r.ok) toast('Cancelled. Nothing was set up.'); } }))));
|
|
109
|
+
}
|
|
110
|
+
if (a.state === 'failed') {
|
|
111
|
+
return sect({ ic: 'spark', title: 'PMOS answer', attrs }, el('div', { class: 'rs-stack' }, head,
|
|
112
|
+
el('div', { class: 'fold w', role: 'alert', 'data-rs-failed': '' }, icon('alert', 'sm'), ' PMOS could not make a proposal: ' + (a.error || 'the run did not finish') + '. Nothing was set up.'),
|
|
113
|
+
el('div', { class: 'chips2' }, pbtn('ghost', 'Dismiss', 'x', { 'data-rs-act': 'cancel-ask', onclick: () => cancelAsk(a.id) }))));
|
|
114
|
+
}
|
|
115
|
+
const P = view(key, normProposal(a.proposal)); const cb = changeBox(key, a.id, redo);
|
|
116
|
+
return sect({ ic: 'spark', title: 'PMOS answer', attrs, right: confTag('') }, el('div', { class: 'rs-stack' }, head,
|
|
117
|
+
lbl('WHAT PMOS FOUND'), ...foundList(P), lbl('PROPOSED REPORT'), proposed(key, P, redo), lbl('LEARNED FROM'), ...[].concat(learnedBlock(P)), lbl('FIXES IT WILL APPLY'), fixesList(P), ...(cb ? [].concat(cb) : []),
|
|
118
|
+
el('div', { class: 'chips2' }, pbtn('primary', 'Set up', 'check', { 'data-rs-act': 'setup', onclick: () => setupFrom({ ask: a.id, proposal: P }) }),
|
|
119
|
+
pbtn('outline', 'Change', 'edit', { 'data-rs-act': 'change', onclick: () => { RS.chg[key] = { ...st, open: true }; redo(); } }),
|
|
120
|
+
pbtn('ghost', 'Cancel', 'x', { 'data-rs-act': 'cancel-ask', onclick: async () => { const r = await cancelAsk(a.id); if (r && r.ok) toast('Cancelled. Nothing was set up.'); } })),
|
|
121
|
+
help('PMOS reads your past copies to learn the sections. It drafts only; you issue.')));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ── a report document in the document sheet ── */
|
|
125
|
+
export function openReportDoc(d, mode) {
|
|
126
|
+
ensureReportCss();
|
|
127
|
+
const setup = RS.setups.find((x) => x.id === d.setup) || null; const cyc = slotText(d.slot);
|
|
128
|
+
const board = d.board || (d.source && d.source !== 'work' ? RS.names(String(d.source).replace(/^dashboard:/, '')) : '') || (setup && setup.name) || 'Portfolio';
|
|
129
|
+
const reload = () => loadReports(RS.ws, true);
|
|
130
|
+
const prev = docsOf(RS.docs, d.setup).filter((x) => x.path !== d.path && String(x.slot) < String(d.slot))[0];
|
|
131
|
+
const rel = (h, rows) => [el('h3', { class: 'ds-h' }, h), el('ul', { class: 'ds-rel' }, rows.map(([ic, t, sub, open]) => el('li', null, icon(ic, 'sm'),
|
|
132
|
+
open ? btn({ class: 'ds-rlk', 'data-rel-open': open.path, onclick: () => { closeSheet(); openReportDoc(open); } }, t, el('small', null, sub)) : el('span', { class: 'ds-rlk' }, t, el('small', null, sub)), el('span'))))];
|
|
133
|
+
const relations = () => [
|
|
134
|
+
rel('Report', [['report', (setup ? setup.name : d.name) + (d.source && d.source !== 'work' ? ' (built from dashboard ' + RS.names(String(d.source).replace(/^dashboard:/, '')) + ')' : ''), (setup ? setup.label || scheduleLabel(setup.schedule) : 'One-off, no schedule') + '. The definition lives under Reports, Set up.', null]]),
|
|
135
|
+
rel('Previous cycle', prev ? [['doc', prev.name + ' · ' + slotText(prev.slot), (prev.state === 'issued' ? 'Issued' : 'Draft') + ' · ' + (formatOf(prev.output).app ? formatOf(prev.output).app + ': ' + (prev.copy && prev.copy.state ? prev.copy.state.replace(/-/g, ' ') : 'not created') : 'PDF: no live copy'), prev]] : [['doc', 'No earlier cycle in PMOS', 'This is the first cycle.', null]]),
|
|
136
|
+
rel('Metric dictionary', [['scale', 'Metric dictionary', 'One definition per metric, used by this report and by the dashboards.', null]]),
|
|
137
|
+
rel('Initiatives it covers', (d.initiatives || (setup && setup.initiatives) || []).length ? (d.initiatives || setup.initiatives).map((i) => ['init', (i.id ? i.id + ' ' : '') + (i.title || ''), 'Linked from Jira', null]) : [['init', 'Not recorded for this report', 'The initiatives appear here when the report names them.', null]])];
|
|
138
|
+
return openDocSheet({ ws: RS.ws, doc: { path: d.path, name: d.name + ' · ' + cyc, ty: 'Report', v: vText(d.v), st: d.state, ini: '', iniName: board }, mode: mode || 'read', relations,
|
|
139
|
+
library: () => Promise.resolve({ ok: true, files: [], edges: [], kinds: [], categories: [], initiatives: {} }), onChanged: reload,
|
|
140
|
+
report: { output: d.output, state: d.state, name: d.name, cycle: cyc, foot: String(board).split(/[\s:·]/)[0],
|
|
141
|
+
onIssue: reload,
|
|
142
|
+
onNext: (doc, ui) => {
|
|
143
|
+
const nx = docsOf(RS.docs, d.setup).find((x) => x.state === 'draft' && x.path !== d.path);
|
|
144
|
+
if (nx) { ui.close(); openReportDoc(nx); return; }
|
|
145
|
+
toast(setup ? 'The next cycle starts on its schedule. PMOS drafts it ' + (setup.next_draft ? 'on ' + dayText(setup.next_draft) : 'then') + '.' : 'This was a one-off. Set up a recurring report to get the next cycle.');
|
|
146
|
+
} } });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ── Send this as a report… (from a dashboard) ── */
|
|
150
|
+
const SEND = { weekly: [{ cadence: 'weekly', day: 'mon', time: '08:00' }, 'Weekly, Mondays'], biweekly: [{ cadence: 'biweekly', day: 'wed', time: '15:00' }, 'Every other Wednesday'], monthly: [{ cadence: 'monthly', dayOfMonth: 1, time: '08:00' }, 'Monthly, the 1st'] };
|
|
151
|
+
export function openSendSheet({ ws, key, title, charts, onDone }) {
|
|
152
|
+
ensureReportCss();
|
|
153
|
+
const st = { name: title + ' snapshot', out: 'pdf', sched: 'monthly', aud: 'Leadership', busy: false };
|
|
154
|
+
const body = el('div', { class: 'rs' });
|
|
155
|
+
const seg = (label, k, opts) => el('div', { class: 'seg', role: 'group', 'aria-label': label }, opts.map(([v, l]) => btn({ 'data-rs-send': k, 'data-v': v, 'aria-pressed': String(st[k] === v), onclick: () => { st[k] = v; paint(); } }, l)));
|
|
156
|
+
const paint = () => {
|
|
157
|
+
const first = nextDraftDate(SEND[st.sched][0]);
|
|
158
|
+
const nameIn = el('input', { type: 'text', 'data-rs-send-name': '', value: st.name, 'aria-label': 'Report name', autocomplete: 'off', oninput: (e) => { st.name = e.target.value; } });
|
|
159
|
+
put(body, el('div', { class: 'calbody brief rs', 'data-rs-send-sheet': '' },
|
|
160
|
+
el('div', { class: 'lh' }, el('span', { class: 'ki' }, icon('report')), el('div', { class: 'rs-min0' }, el('b', null, 'Send ' + title + ' as a report'), el('div', { class: 'dim' }, 'The dashboard stays live. Each cycle is a snapshot document in Reports.'))),
|
|
161
|
+
sect({ ic: 'report', title: 'Proposed report' }, kv([['Name', nameIn], ['Built from', [srcChip('dashboard:' + key), el('span', { class: 'dim rs-help' }, ' ' + charts + ' chart' + (charts === 1 ? '' : 's') + ', as they are on the day')]],
|
|
162
|
+
['Output', seg('Output', 'out', [['pdf', 'PDF'], ['google-slides', 'Google Slides']])],
|
|
163
|
+
['Schedule', [seg('Schedule', 'sched', [['weekly', 'Weekly'], ['biweekly', 'Every 2 weeks'], ['monthly', 'Monthly']]), help(SEND[st.sched][1] + '. First draft ' + (first ? dayText(first) : 'on its first day') + '.')]],
|
|
164
|
+
['Audience', seg('Audience', 'aud', [['Leadership', 'Leadership'], ['Team', 'Team']])]])),
|
|
165
|
+
el('div', { class: 'fold' }, icon('shield', 'sm'), ' PMOS drafts only. It writes to Google only when you press Create the copy. Nothing is sent.'),
|
|
166
|
+
el('div', { class: 'chips2' }, pbtn('primary', 'Set up', 'check', { 'data-rs-act': 'send-go', disabled: st.busy, onclick: async () => {
|
|
167
|
+
st.busy = true; paint();
|
|
168
|
+
const r = await fromDashboard({ ws, key, name: (st.name || '').trim() || title + ' snapshot', output: st.out, schedule: SEND[st.sched][0], audience: st.aud });
|
|
169
|
+
st.busy = false; if (!r || !r.ok) { paint(); return; }
|
|
170
|
+
closeSheet(); toast('Set up. First draft ' + (first ? dayText(first) : 'on its first day') + '. Nothing is sent.'); if (onDone) onDone(r.setup);
|
|
171
|
+
} }), pbtn('ghost', 'Cancel', 'x', { 'data-rs-act': 'send-cancel', onclick: () => closeSheet() }))));
|
|
172
|
+
};
|
|
173
|
+
openSheet({ title: 'Send this as a report', icon: 'report', wide: true, body }); paint();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ── the tab ── */
|
|
177
|
+
export function mountReportSetup(host, ctx) {
|
|
178
|
+
ensureReportCss();
|
|
179
|
+
const ws = (ctx && ctx.ws) || RS.ws; if (ws) RS.ws = ws;
|
|
180
|
+
const root = el('div', { class: 'calbody brief rs', 'data-rs-tab': '' });
|
|
181
|
+
const inp = el('input', { type: 'text', 'data-rs-ask-in': '', value: RS.askText, placeholder: 'Describe a report you send, for example: my TSC update every month, due a week before TSC', 'aria-label': 'Describe a report you send', autocomplete: 'off',
|
|
182
|
+
oninput: () => { RS.askText = inp.value; }, onkeydown: (e) => { if (e.key === 'Enter') send.click(); } });
|
|
183
|
+
const send = pbtn('primary', 'Set up', 'spark', { 'data-rs-act': 'ask', onclick: async () => {
|
|
184
|
+
const v = inp.value.trim(); if (!v) { inp.focus(); return; }
|
|
185
|
+
send.disabled = true; const r = await askPmos(v); send.disabled = false; if (r && r.ok) draw(); } });
|
|
186
|
+
const answers = el('div', { class: 'rs-stack', 'data-rs-answers': '' });
|
|
187
|
+
const askBox = el('section', { class: 'card sect', 'data-rs-askbox': '', 'aria-label': 'Ask PMOS' }, el('div', { class: 'sh2' }, el('h2', null, icon('spark', 'sm'), 'Ask PMOS')),
|
|
188
|
+
el('div', { class: 'form' }, inp, send), help('PMOS reads your past copies to learn the sections. It drafts only; you issue.'), answers);
|
|
189
|
+
const foundHost = el('div', { 'data-rs-found': '' }); const setupHost = el('div', { 'data-rs-setups': '' }); const docsHost = el('div', { 'data-rs-docs': '' });
|
|
190
|
+
root.append(el('div', { class: 'fold' }, icon('mail', 'sm'), 'Documents you send. PMOS drafts; you issue. Nothing is sent.'), askBox, foundHost, setupHost, docsHost);
|
|
191
|
+
host.replaceChildren(root);
|
|
192
|
+
|
|
193
|
+
const found = () => RS.findings.filter((f) => !RS.hidden.has(f.id));
|
|
194
|
+
function foundRow(f) {
|
|
195
|
+
const open = () => openFindingSheet(f.id);
|
|
196
|
+
return el('li', { class: 'nrow', 'data-rs-row': f.id }, el('span', { class: 'ki hot' }, icon('report')),
|
|
197
|
+
el('div', { class: 'nb' }, btn({ class: 'tt', 'data-rs-act': 'open', onclick: open }, f.name), el('div', { class: 'dim rs-help' }, f.why || ''),
|
|
198
|
+
el('div', { class: 'chips' }, (f.evidence || []).map((e) => { const [ic, nm] = evidenceKind(e.kind); return chip(icon(ic, 'sm'), nm + ': ' + e.text); }), confTag(f.confidence))),
|
|
199
|
+
el('div', { class: 'acts' }, pbtn('primary', 'Set up', 'check', { 'data-rs-act': 'setup', onclick: () => setupFrom({ finding: f.id, proposal: view(f.id, normProposal(f.proposal)) }) }),
|
|
200
|
+
pbtn('outline', 'Change first', 'edit', { 'data-rs-act': 'open-change', onclick: () => openFindingSheet(f.id, true) }),
|
|
201
|
+
pbtn('ghost', 'Not a report', 'x', { 'data-rs-act': 'dismiss', onclick: () => dismissLater(f.id) })));
|
|
202
|
+
}
|
|
203
|
+
const cycRows = (s) => el('ul', { class: 'chgl', 'aria-label': 'All cycles of ' + s.name, 'data-rs-cycles': s.id }, docsOf(RS.docs, s.id).map((d) => el('li', null, icon('doc', 'sm'),
|
|
204
|
+
el('span', null, btn({ class: 'tt', onclick: () => openReportDoc(d) }, slotText(d.slot)), ' ', el('span', { class: 'dim rs-help' }, d.state === 'issued' ? 'Issued' : 'Draft')),
|
|
205
|
+
el('span', { class: 'chips2' }, stag(d), copyChip(d.output, d.copy), pbtn('ghost', 'Open', 'out', { onclick: () => openReportDoc(d) })))));
|
|
206
|
+
function openSetup(s) {
|
|
207
|
+
const L = docsOf(RS.docs, s.id); const d = L.find((x) => x.state === 'draft') || L[0];
|
|
208
|
+
if (d) openReportDoc(d); else toast('No draft yet. The first draft is ' + (s.next_draft ? dayText(s.next_draft) : 'when you confirm the day') + '.');
|
|
209
|
+
}
|
|
210
|
+
function setupRow(s) {
|
|
211
|
+
const paused = s.status === 'paused'; const cy = docsOf(RS.docs, s.id); const n = s.cycles != null ? s.cycles : cy.length; const open = RS.cyc.has(s.id);
|
|
212
|
+
return el('li', { class: 'nrow', 'data-rs-reg': s.id }, el('span', { class: 'ki' }, icon('report')),
|
|
213
|
+
el('div', { class: 'nb' }, btn({ class: 'tt', 'data-rs-act': 'open-reg', onclick: () => openSetup(s) }, s.name),
|
|
214
|
+
el('div', { class: 'chips' }, RS.newIds.has(s.id) ? el('span', { class: 'tag acc' }, 'New') : null, srcChip(s.source), outChip(s.output), copyChip(s.output, s.copy), paused ? el('span', { class: 'tag warn' }, 'Paused') : null,
|
|
215
|
+
chip(icon('cal', 'sm'), s.label || scheduleLabel(s.schedule)), paused ? null : chip(icon('clock', 'sm'), 'Next draft ' + (s.next_draft ? dayText(s.next_draft) : 'not set yet')),
|
|
216
|
+
chip(icon('mail', 'sm'), 'Last issued ' + (s.last_issued ? dayText(s.last_issued) : 'not yet'))),
|
|
217
|
+
open ? cycRows(s) : null),
|
|
218
|
+
el('div', { class: 'acts' }, pbtn('outline', 'Open', 'out', { 'data-rs-act': 'open-reg', onclick: () => openSetup(s) }),
|
|
219
|
+
pbtn('ghost', 'All cycles (' + n + ')', 'fold', { 'data-rs-act': 'cyc', 'aria-expanded': String(open), onclick: () => { if (RS.cyc.has(s.id)) RS.cyc.delete(s.id); else RS.cyc.add(s.id); drawSetups(); } }),
|
|
220
|
+
pbtn('ghost', paused ? 'Resume' : 'Pause', paused ? 'play' : 'pause', { 'data-rs-act': 'pause', onclick: () => pauseResume(s, !paused) })));
|
|
221
|
+
}
|
|
222
|
+
const isWork = (d) => !d.source || d.source === 'work';
|
|
223
|
+
function docRow(d) {
|
|
224
|
+
const f = formatOf(d.output);
|
|
225
|
+
return el('li', { class: 'nrow', 'data-rs-doc': d.path }, el('span', { class: 'ki' }, icon(f.ic)),
|
|
226
|
+
el('div', { class: 'nb' }, btn({ class: 'tt', 'data-rs-act': 'opendoc', onclick: () => openReportDoc(d) }, d.name + ' · ' + slotText(d.slot)),
|
|
227
|
+
el('div', { class: 'chips' }, stag(d), outChip(d.output), copyChip(d.output, d.copy), srcChip(d.source), el('span', { class: 'tag' }, d.made_by === 'schedule' ? 'Made by schedule' : d.made_by === 'agent' ? 'Made by PMOS' : 'Made by you'), el('span', { class: 'chip2 mono' }, vText(d.v)))),
|
|
228
|
+
el('div', { class: 'acts' }, pbtn('outline', 'Open', 'out', { 'data-rs-act': 'opendoc', onclick: () => openReportDoc(d) })));
|
|
229
|
+
}
|
|
230
|
+
function drawFound() {
|
|
231
|
+
const f = found(); const fin = RS.finder;
|
|
232
|
+
const none = fin && fin.cadence === 'off' ? 'The report finder is off. Turn it on in Settings to see what PMOS finds.' : 'Nothing new found. PMOS looks again ' + (fin && fin.next_run ? dayText(fin.next_run) : 'next Monday') + '.';
|
|
233
|
+
put(foundHost, sect({ ic: 'search', title: 'Found in your work', count: f.length, attrs: { 'data-pg': 'rs-found' } }, f.length ? list(f.map(foundRow)) : el('div', { class: 'fold', 'data-rs-nofound': '' }, icon('check', 'sm'), none),
|
|
234
|
+
help('From your calendar, Slack and the subjects of mail you sent. Never message bodies. ', lk({ 'data-rs-act': 'finder-settings', onclick: () => goTo({ tab: 'settings', section: 'connectors' }) }, 'Report finder settings'))));
|
|
235
|
+
}
|
|
236
|
+
function drawSetups() {
|
|
237
|
+
put(setupHost, sect({ ic: 'cal', title: 'Set up', count: RS.setups.length, attrs: { 'data-pg': 'rs-regular' } }, RS.setups.length ? list(RS.setups.map(setupRow)) : el('div', { class: 'fold', 'data-rs-nosetup': '' }, icon('check', 'sm'), 'No recurring report yet. Ask PMOS above, or set one up from what it found.')));
|
|
238
|
+
}
|
|
239
|
+
function drawDocs() {
|
|
240
|
+
const F = RS.docF; const all = RS.docs; const nState = (v) => all.filter((d) => d.state === v).length; const nSrc = (w) => all.filter((d) => isWork(d) === w).length;
|
|
241
|
+
const rows = all.filter((d) => (F.state === 'all' || d.state === F.state) && (F.src === 'all' || (F.src === 'work') === isWork(d)));
|
|
242
|
+
const seg = (k, label, opts) => el('div', { class: 'seg', role: 'group', 'aria-label': label }, opts.map(([v, l, c]) => btn({ 'data-rs-act': 'docf', 'data-k': k, 'data-v': v, 'aria-pressed': String(F[k] === v),
|
|
243
|
+
onclick: () => { F[k] = v; drawDocs(); } }, l, c != null ? [' ', el('span', { class: 'bdg g' }, String(c))] : null)));
|
|
244
|
+
put(docsHost, sect({ ic: 'doc', title: 'Documents', count: rows.length, attrs: { 'data-pg': 'rs-docs' } }, el('div', { class: 'calbar' }, seg('state', 'State', [['all', 'All', all.length], ['draft', 'Draft', nState('draft')], ['issued', 'Issued', nState('issued')]]),
|
|
245
|
+
seg('src', 'Source', [['all', 'All sources'], ['work', 'Your work'], ['dash', 'Dashboards']])),
|
|
246
|
+
rows.length ? list(rows.map(docRow)) : el('div', { class: 'fold', 'data-rs-nodoc': '' }, icon('check', 'sm'), all.length ? 'No document matches this filter.' : 'No report document yet. A draft appears here on its day.')));
|
|
247
|
+
}
|
|
248
|
+
function drawAnswers() { put(answers, ...RS.asks.map((a) => askCard(a, drawAnswers))); }
|
|
249
|
+
function draw() {
|
|
250
|
+
if (!RS.loaded) { put(foundHost, el('p', { class: 'dim mono', role: 'status', 'data-rs-loading': '' }, 'Reading the repository')); return; }
|
|
251
|
+
if (RS.err && !RS.setups.length && !RS.docs.length) put(foundHost, el('div', { class: 'fold w', role: 'alert', 'data-rs-error': '' }, icon('alert', 'sm'), RS.err));
|
|
252
|
+
else drawFound();
|
|
253
|
+
drawAnswers(); drawSetups(); drawDocs();
|
|
254
|
+
}
|
|
255
|
+
const off = onReports(draw); draw();
|
|
256
|
+
loadReports(ws, true).then(() => {
|
|
257
|
+
if (RS.openFinding) { const id = RS.openFinding; RS.openFinding = null; openFindingSheet(id); }
|
|
258
|
+
});
|
|
259
|
+
return { dispose: off, refresh: () => loadReports(RS.ws, true), root };
|
|
260
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/* pm/c3-report-store.js — the shared state and small views of the report workspace (initiative report-workspace, PRD sections 3, 4 and 6; mockup 41-reportsetup.js).
|
|
2
|
+
* One store for the three places that read it: Reports (c3-report-setup.js), the Briefing card, and the Settings block. It reads
|
|
3
|
+
* GET /api/report-setups?ws= { setups, findings, asks, finder } GET /api/report-documents?ws= { docs }
|
|
4
|
+
* and presses POST /api/report-setups/{ask,change,cancel,setup,dismiss,pause,resume,from-dashboard,finder} through the page's pmPost.
|
|
5
|
+
* Nothing here sends anything. A press that PMOS can take back (Not a report) waits five seconds before it posts, so Undo costs nothing.
|
|
6
|
+
* RS the state; onReports(fn) -> off() runs after every change
|
|
7
|
+
* loadReports(ws, force) reads both; polls while an ask runs
|
|
8
|
+
* askPmos, changeIt, cancelAsk, setupFrom, dismissLater, pauseResume, fromDashboard, setFinder the presses
|
|
9
|
+
* reportFoundSection(S) the Briefing card; finderBlock(ws) the Settings block
|
|
10
|
+
* Pure parts (Node-testable): scheduleLabel, nextDraftDate, dayText, copyStateOf, evidenceKind, bestFinding, docsOf, draftsWaiting, normProposal. */
|
|
11
|
+
import { el } from './dom.js';
|
|
12
|
+
import { icon, pbtn, chip, tag, sect, put, list, lk, btn } from './c3-ini-ui.js';
|
|
13
|
+
import { pmGet, pmPost } from './http.js';
|
|
14
|
+
import { toast } from './c3-toast.js';
|
|
15
|
+
import { onBriefing } from './shell-ticker.js';
|
|
16
|
+
import { FORMATS, formatOf, ensureReportCss } from './c3-doc-formats.js';
|
|
17
|
+
|
|
18
|
+
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
19
|
+
const DAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
20
|
+
const dayIdx = (d) => DAYS[String(d || '').slice(0, 3).toLowerCase()];
|
|
21
|
+
const ord = (n) => n + (n % 10 === 1 && n !== 11 ? 'st' : n % 10 === 2 && n !== 12 ? 'nd' : n % 10 === 3 && n !== 13 ? 'rd' : 'th');
|
|
22
|
+
|
|
23
|
+
/** "Every other Wed 15:00, draft only" from a schedule { cadence, day, dayOfMonth, time }. */
|
|
24
|
+
export function scheduleLabel(s) {
|
|
25
|
+
if (!s) return 'No schedule, draft only';
|
|
26
|
+
const day = dayIdx(s.day); const dn = day == null ? '' : WD[day]; const t = s.time ? ' ' + s.time : '';
|
|
27
|
+
if (s.cadence === 'daily') return 'Every working day' + t + ', draft only';
|
|
28
|
+
if (s.cadence === 'weekly') return 'Every ' + (dn || 'Mon') + t + ', draft only';
|
|
29
|
+
if (s.cadence === 'biweekly') return 'Every other ' + (dn || 'Mon') + t + ', draft only';
|
|
30
|
+
if (s.cadence === 'monthly') return (s.dayOfMonth ? 'Monthly on the ' + ord(+s.dayOfMonth) + t : 'Monthly, day to be confirmed') + ', draft only';
|
|
31
|
+
return 'Draft only';
|
|
32
|
+
}
|
|
33
|
+
/** The first date a schedule fires on or after `from` (a Date), or null when the schedule has no day yet. */
|
|
34
|
+
export function nextDraftDate(s, from = new Date()) {
|
|
35
|
+
if (!s) return null;
|
|
36
|
+
const d = new Date(from); d.setHours(12, 0, 0, 0);
|
|
37
|
+
if (s.cadence === 'monthly') {
|
|
38
|
+
const n = +s.dayOfMonth; if (!n) return null;
|
|
39
|
+
for (let i = 0; i < 3; i++) { const c = new Date(d.getFullYear(), d.getMonth() + i, n, 12); while (c.getDay() === 0 || c.getDay() === 6) c.setDate(c.getDate() - 1); if (c > d) return c; }
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const day = s.cadence === 'daily' ? null : dayIdx(s.day); if (s.cadence !== 'daily' && day == null) return null;
|
|
43
|
+
for (let i = 1; i <= 14; i++) { const c = new Date(d); c.setDate(d.getDate() + i); if (day == null ? c.getDay() % 6 !== 0 : c.getDay() === day) return c; }
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/** 'Mon 19 Oct' for a Date or an ISO text; any other text comes back as it is. */
|
|
47
|
+
export function dayText(v) {
|
|
48
|
+
if (v instanceof Date) return WD[v.getDay()] + ' ' + v.getDate() + ' ' + MON[v.getMonth()];
|
|
49
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v || '')); if (!m) return String(v || '');
|
|
50
|
+
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
51
|
+
return WD[d.getUTCDay()] + ' ' + (+m[3]) + ' ' + MON[+m[2] - 1];
|
|
52
|
+
}
|
|
53
|
+
export const EVIDENCE = { mail: ['mail', 'Mail'], calendar: ['cal', 'Calendar'], slack: ['reply', 'Slack'], drive: ['repo', 'Drive'], confluence: ['notes', 'Confluence'] };
|
|
54
|
+
export const evidenceKind = (k) => EVIDENCE[k] || ['info', String(k || 'Source')];
|
|
55
|
+
/** The copy state words of a setup or a document: [label word, icon, class]. */
|
|
56
|
+
export function copyStateOf(copy) {
|
|
57
|
+
const st = (copy && copy.state) || 'none';
|
|
58
|
+
return { none: ['not created', 'info', ''], 'in-step': ['in step', 'check', 'ds-good'], 'pmos-ahead': ['PMOS ahead', 'up', 'ds-acc'], 'copy-changed': ['copy changed', 'alert', 'ds-warn'], 'not-checked': ['not checked', 'info', ''], 'in-progress': ['in progress', 'clock', 'ds-acc'] }[st] || ['not checked', 'info', ''];
|
|
59
|
+
}
|
|
60
|
+
/** The finding the Briefing names: the first Likely one that is not hidden, else the first not hidden. */
|
|
61
|
+
export const bestFinding = (findings, hidden) => { const f = (findings || []).filter((x) => !(hidden && hidden.has(x.id))); return f.find((x) => x.confidence === 'likely') || f[0] || null; };
|
|
62
|
+
export const draftsWaiting = (docs) => (docs || []).filter((d) => d.state === 'draft').length;
|
|
63
|
+
/** The documents of a setup, newest slot first. */
|
|
64
|
+
export const docsOf = (docs, id) => (docs || []).filter((d) => d.setup === id).sort((a, b) => String(b.slot).localeCompare(String(a.slot)));
|
|
65
|
+
/** A proposal as the page draws it: a learned_from that is a name or { name, why }; fixes and sections as lists. */
|
|
66
|
+
export function normProposal(p) {
|
|
67
|
+
const q = p || {}; const lf = q.learned_from;
|
|
68
|
+
return { ...q, learned: lf && typeof lf === 'object' ? { name: lf.name || lf.title || '', why: lf.why || lf.reason || '' } : lf ? { name: String(lf), why: '' } : null,
|
|
69
|
+
fixes: Array.isArray(q.fixes) ? q.fixes : [], sections: Array.isArray(q.sections) ? q.sections : [], sources: Array.isArray(q.sources) ? q.sources : [], found: Array.isArray(q.found) ? q.found : [] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ── the state ── */
|
|
73
|
+
export const RS = { ws: '', setups: [], findings: [], asks: [], finder: null, docs: [], loaded: false, err: '', hidden: new Set(), newIds: new Set(), askText: '', docF: { state: 'all', src: 'all' }, cyc: new Set(),
|
|
74
|
+
edit: {}, chg: {}, openFinding: null, names: (k) => k };
|
|
75
|
+
const subs = new Set(); let timer = null; let inflight = null;
|
|
76
|
+
export const onReports = (fn) => { subs.add(fn); return () => { subs.delete(fn); if (!subs.size) stopPoll(); }; };
|
|
77
|
+
const emit = () => { for (const f of [...subs]) { try { f(); } catch { /* one view's error is its own */ } } };
|
|
78
|
+
const running = () => RS.asks.some((a) => a.state === 'running');
|
|
79
|
+
function stopPoll() { if (timer) { clearInterval(timer); timer = null; } }
|
|
80
|
+
function poll() { if (!timer && running() && subs.size) timer = setInterval(() => { if (!running() || !subs.size) { stopPoll(); return; } loadReports(RS.ws, true); }, 2500); }
|
|
81
|
+
|
|
82
|
+
/** Read the setups, findings, asks, finder and the documents. Kept: a second call while one runs shares it; `force` reads again. */
|
|
83
|
+
export function loadReports(ws, force) {
|
|
84
|
+
if (ws) RS.ws = ws;
|
|
85
|
+
if (inflight && !force) return inflight;
|
|
86
|
+
const q = RS.ws ? '?ws=' + encodeURIComponent(RS.ws) : '';
|
|
87
|
+
const run = Promise.all([Promise.resolve(pmGet('/api/report-setups' + q)).catch(() => null), Promise.resolve(pmGet('/api/report-documents' + q)).catch(() => null)]).then(([a, b]) => {
|
|
88
|
+
inflight = null;
|
|
89
|
+
if (a && a.ok !== false && Array.isArray(a.setups)) { RS.setups = a.setups; RS.findings = a.findings || []; RS.asks = a.asks || []; RS.finder = a.finder || RS.finder; RS.err = ''; } else RS.err = (a && (a.error || a.reason)) || 'The report set-ups could not be read.';
|
|
90
|
+
if (b && b.ok !== false && Array.isArray(b.docs)) RS.docs = b.docs; else if (!RS.err) RS.err = (b && (b.error || b.reason)) || 'The report documents could not be read.';
|
|
91
|
+
RS.loaded = true; emit(); poll(); return RS;
|
|
92
|
+
});
|
|
93
|
+
inflight = run; return run;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ── the presses ── */
|
|
97
|
+
const post = (path, body) => Promise.resolve(pmPost('/api/report-setups/' + path, { ws: RS.ws, ...body })).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
|
|
98
|
+
const fail = (r, what) => toast({ text: what + ': ' + ((r && (r.error || r.reason)) || 'the workspace did not answer') + '.', tone: 'error', timeout: 6000 });
|
|
99
|
+
export async function askPmos(text) {
|
|
100
|
+
const r = await post('ask', { text }); if (!r || !r.ok) { fail(r, 'PMOS did not start'); return r; }
|
|
101
|
+
await loadReports(RS.ws, true); return r;
|
|
102
|
+
}
|
|
103
|
+
export async function changeIt(id, text) {
|
|
104
|
+
const r = await post('change', { id, text }); if (!r || !r.ok) { fail(r, 'The change was not sent'); return r; }
|
|
105
|
+
await loadReports(RS.ws, true); return r;
|
|
106
|
+
}
|
|
107
|
+
export async function cancelAsk(id) {
|
|
108
|
+
const r = await post('cancel', { id }); if (!r || !r.ok) { fail(r, 'It was not cancelled'); return r; }
|
|
109
|
+
await loadReports(RS.ws, true); return r;
|
|
110
|
+
}
|
|
111
|
+
/** Set up from an ask or a finding: body = { ask } or { finding }, plus the proposal as the PM left it. */
|
|
112
|
+
export async function setupFrom(body) {
|
|
113
|
+
const r = await post('setup', body); if (!r || !r.ok) { fail(r, 'It was not set up'); return r; }
|
|
114
|
+
if (r.setup && r.setup.id) RS.newIds.add(r.setup.id);
|
|
115
|
+
await loadReports(RS.ws, true);
|
|
116
|
+
const fd = r.first_draft; const first = (r.setup && r.setup.next_draft) || (fd && typeof fd === 'object' ? fd.day || fd.slot : fd);
|
|
117
|
+
toast('Set up. First draft ' + (first ? dayText(first) : 'once you confirm the day') + '. Nothing is sent.');
|
|
118
|
+
return r;
|
|
119
|
+
}
|
|
120
|
+
/** Not a report: the finding leaves the list at once, the POST waits five seconds, and Undo cancels it. */
|
|
121
|
+
export function dismissLater(id, onChange) {
|
|
122
|
+
RS.hidden.add(id); emit(); if (onChange) onChange();
|
|
123
|
+
const t = setTimeout(async () => { const r = await post('dismiss', { finding: id }); if (!r || !r.ok) { RS.hidden.delete(id); fail(r, 'It was not dismissed'); } await loadReports(RS.ws, true); if (onChange) onChange(); }, 5000);
|
|
124
|
+
toast({ text: "PMOS won't suggest this again", actionLabel: 'Undo', timeout: 6000, onAction: () => { clearTimeout(t); RS.hidden.delete(id); emit(); if (onChange) onChange(); } });
|
|
125
|
+
}
|
|
126
|
+
export async function pauseResume(setup, pause) {
|
|
127
|
+
const r = await post(pause ? 'pause' : 'resume', { id: setup.id }); if (!r || !r.ok) { fail(r, 'It was not ' + (pause ? 'paused' : 'resumed')); return r; }
|
|
128
|
+
await loadReports(RS.ws, true);
|
|
129
|
+
toast({ text: pause ? 'Paused ' + setup.name + '. No draft until you resume' : 'Resumed ' + setup.name, actionLabel: 'Undo', timeout: 6000, onAction: () => pauseResume(setup, !pause) });
|
|
130
|
+
return r;
|
|
131
|
+
}
|
|
132
|
+
export async function fromDashboard(body) {
|
|
133
|
+
const r = await post('from-dashboard', body); if (!r || !r.ok) { fail(r, 'It was not set up'); return r; }
|
|
134
|
+
if (r.setup && r.setup.id) RS.newIds.add(r.setup.id);
|
|
135
|
+
await loadReports(RS.ws, true); return r;
|
|
136
|
+
}
|
|
137
|
+
export async function setFinder(patch, msg) {
|
|
138
|
+
const r = await post('finder', patch); if (!r || !r.ok) { fail(r, 'The finder was not changed'); return r; }
|
|
139
|
+
if (r.finder) RS.finder = r.finder; emit(); if (msg) toast(msg); return r;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Go to a page of the console: the shell listens (c3-shell.js). detail = { tab, view?, section?, finding? }. */
|
|
143
|
+
export function goTo(detail) {
|
|
144
|
+
if (detail.finding) RS.openFinding = detail.finding;
|
|
145
|
+
try { window.dispatchEvent(new CustomEvent('pmos:go-tab', { detail })); } catch { /* no events */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ── chips shared by the rows ── */
|
|
149
|
+
export const outChip = (o) => { const f = formatOf(o); return chip(icon(f.ic, 'sm'), f.l); };
|
|
150
|
+
export function copyChip(output, copy) {
|
|
151
|
+
if (output === 'pdf') return null;
|
|
152
|
+
const f = formatOf(output); const [word, ic, cls] = copyStateOf(copy);
|
|
153
|
+
return el('span', { class: 'chip2 ' + cls, 'data-rs-copy': (copy && copy.state) || 'none' }, icon(ic, 'sm'), f.app + ': ' + word);
|
|
154
|
+
}
|
|
155
|
+
export const srcChip = (source) => (!source || source === 'work' ? chip(icon('spark', 'sm'), 'Built from: your work') : chip(icon('report', 'sm'), 'Built from: dashboard ' + RS.names(String(source).replace(/^dashboard:/, ''))));
|
|
156
|
+
|
|
157
|
+
/* ── Briefing: the Report found card ── */
|
|
158
|
+
function foundCard(f) {
|
|
159
|
+
const P = normProposal(f.proposal);
|
|
160
|
+
const sentence = (f.why || 'You send this report regularly.') + ' Set it up so PMOS drafts it?';
|
|
161
|
+
return sect({ ic: 'report', title: 'Report found', attrs: { 'data-c3pg': 'rs-card', 'data-rs-card': f.id, 'aria-label': 'Report found' } },
|
|
162
|
+
el('div', { class: 'rs-stack' },
|
|
163
|
+
el('div', { class: 'chips' }, f.name ? chip(icon('report', 'sm'), f.name) : null, el('span', { class: 'tag acc' }, 'Found in your work')),
|
|
164
|
+
el('p', null, sentence),
|
|
165
|
+
el('div', { class: 'chips' }, (f.evidence || []).map((e) => { const [ic, nm] = evidenceKind(e.kind); return chip(icon(ic, 'sm'), nm + ': ' + e.text); })),
|
|
166
|
+
el('div', { class: 'fold' }, icon('info', 'sm'), ' PMOS drafts only. Nothing is sent.'),
|
|
167
|
+
el('div', { class: 'chips2' },
|
|
168
|
+
pbtn('primary', 'Set up', 'check', { 'data-rs-act': 'b-setup', onclick: async () => { const r = await setupFrom({ finding: f.id, proposal: P }); if (r && r.ok) goTo({ tab: 'rep', view: 'reports' }); } }),
|
|
169
|
+
pbtn('outline', 'See what PMOS found', 'search', { 'data-rs-act': 'b-see', onclick: () => goTo({ tab: 'rep', view: 'reports', finding: f.id }) }),
|
|
170
|
+
pbtn('ghost', 'Not a report', 'x', { 'data-rs-act': 'b-no', onclick: () => dismissLater(f.id) }))));
|
|
171
|
+
}
|
|
172
|
+
/** The Briefing's Report found card: a container that holds the card while a finding waits, and nothing otherwise. */
|
|
173
|
+
export function reportFoundSection(S) {
|
|
174
|
+
ensureReportCss();
|
|
175
|
+
const host = el('div', { 'data-rs-host': '' }); let sig = '';
|
|
176
|
+
const fill = () => {
|
|
177
|
+
const f = RS.loaded ? bestFinding(RS.findings, RS.hidden) : null;
|
|
178
|
+
const g = JSON.stringify(f && [f.id, f.name, f.why]); if (g === sig) return; sig = g;
|
|
179
|
+
put(host, f ? foundCard(f) : null);
|
|
180
|
+
};
|
|
181
|
+
onReports(fill);
|
|
182
|
+
const go = () => loadReports((S && S.ws) || RS.ws).then(fill, () => {});
|
|
183
|
+
onBriefing(() => { go(); });
|
|
184
|
+
go();
|
|
185
|
+
return host;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ── Settings: the Report finder block ── */
|
|
189
|
+
/** The block under Connectors: Weekly | Off, and the sources, with Mail subjects off until the PM turns it on. */
|
|
190
|
+
export function finderBlock(ws) {
|
|
191
|
+
ensureReportCss();
|
|
192
|
+
const host = el('div', { 'data-rs-finder-host': '' });
|
|
193
|
+
const draw = () => {
|
|
194
|
+
const F = RS.finder || { cadence: 'weekly', sources: {} }; const src = F.sources || {};
|
|
195
|
+
const seg = (label, opts, cur, onPick) => el('div', { class: 'seg', role: 'group', 'aria-label': label }, opts.map(([v, l]) => btn({ 'aria-pressed': String(String(cur) === String(v)), 'data-rs-act': 'finder', 'data-v': String(v), onclick: () => onPick(v) }, l)));
|
|
196
|
+
const row = (t, help, right) => el('li', { class: 'nrow' }, el('div', { class: 'nb' }, el('span', { class: 'tt' }, t), el('div', { class: 'dim rs-help' }, help)), el('div', { class: 'acts' }, ...right));
|
|
197
|
+
const on = [['calendar', 'Calendar', 'cal'], ['slack', 'Slack', 'reply'], ['drive', 'Drive', 'repo']].filter(([k]) => src[k] !== false);
|
|
198
|
+
const names = on.map((x) => x[1]); const lead = names.length ? names.slice(0, -1).join(', ') + (names.length > 1 ? ' and ' : '') + names[names.length - 1] + (names.length > 1 ? ' are on.' : ' is on.') : 'No source is on.';
|
|
199
|
+
put(host, sect({ ic: 'search', title: 'Report finder', attrs: { 'data-rs-finder': '' } }, list([
|
|
200
|
+
row('Look for reports you send', F.cadence === 'off' ? 'Off. PMOS does not look for reports.' : 'PMOS looks every Monday and shows what it found on Reports.' + (F.last_run ? ' Last looked ' + dayText(F.last_run) + '.' : ''),
|
|
201
|
+
[seg('How often PMOS looks for reports you send', [['weekly', 'Weekly'], ['off', 'Off']], F.cadence === 'off' ? 'off' : 'weekly', (v) => setFinder({ cadence: v }, v === 'off' ? 'Report finder is off' : 'Report finder looks weekly'))]),
|
|
202
|
+
row('Sources', lead + ' Sent mail is read for subjects and recipient counts only, never bodies.',
|
|
203
|
+
[...on.map(([, l, ic]) => chip(icon(ic, 'sm'), l)), seg('Sent mail subjects', [[false, 'Off'], [true, 'On']], !!src.mail, (v) => setFinder({ sources: { mail: v === 'true' || v === true } }, v === 'true' || v === true ? 'Report finder reads sent mail subjects' : 'Report finder skips sent mail'))])])));
|
|
204
|
+
};
|
|
205
|
+
onReports(draw); draw();
|
|
206
|
+
loadReports(ws || RS.ws).then(draw, () => {});
|
|
207
|
+
return host;
|
|
208
|
+
}
|
|
@@ -60,3 +60,7 @@
|
|
|
60
60
|
.ph .tiles,.rp3.ph .tiles{grid-template-columns:minmax(0,1fr)}
|
|
61
61
|
.rp3 button.ib.ib{width:28px;padding:0;justify-content:center;color:var(--foreground)}
|
|
62
62
|
.rp3 button.ib.ib:disabled{opacity:.35}
|
|
63
|
+
|
|
64
|
+
/* the rule line under the tabs: how Reports is organised (Live, Sent, Building blocks) */
|
|
65
|
+
.rp3 .rp3-rule { margin: 0 0 var(--sp-3); align-items: center; }
|
|
66
|
+
.rp3 .rp3-rulelbl { font-size: var(--fs-1); }
|