create-pmos 0.515.0 → 0.517.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +41 -71
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/payload/state/log.md +1 -1
  7. package/workspace/briefing-premium-calendar-test.js +6 -3
  8. package/workspace/briefing-premium-insights-test.js +2 -2
  9. package/workspace/changelog.json +40 -0
  10. package/workspace/ev-everywhere-test.js +3 -3
  11. package/workspace/fixtures/premium-viewer-fixtures.js +4 -2
  12. package/workspace/guide/agent-capabilities.md +73 -0
  13. package/workspace/guide/board.md +7 -63
  14. package/workspace/guide/breakdown.md +8 -108
  15. package/workspace/guide/briefing-premium.md +5 -526
  16. package/workspace/guide/console-calendar.md +7 -206
  17. package/workspace/guide/docs.md +10 -125
  18. package/workspace/guide/home.md +8 -5
  19. package/workspace/guide/index.md +79 -78
  20. package/workspace/guide/initiatives-and-next-steps.md +6 -19
  21. package/workspace/guide/layout.md +23 -230
  22. package/workspace/guide/live-notes.md +5 -7
  23. package/workspace/guide/my-work.md +8 -172
  24. package/workspace/guide/notes.md +14 -21
  25. package/workspace/guide/people.md +6 -39
  26. package/workspace/guide/report-design.md +2 -2
  27. package/workspace/guide/report-structure.md +1 -1
  28. package/workspace/guide/reports-needs-you.md +34 -0
  29. package/workspace/guide/reports-set-up.md +9 -10
  30. package/workspace/guide/reports.md +17 -1
  31. package/workspace/guide/settings.md +16 -69
  32. package/workspace/guide/today.md +12 -188
  33. package/workspace/lib/report-acts.js +189 -0
  34. package/workspace/lib/report-checks.js +3 -0
  35. package/workspace/lib/report-cli.js +112 -3
  36. package/workspace/lib/report-engine.js +779 -0
  37. package/workspace/lib/report-export.js +36 -1
  38. package/workspace/lib/report-pyramid.js +20 -0
  39. package/workspace/lib/routes-insights-premium.js +17 -3
  40. package/workspace/lib/routes-ready-reports.js +11 -2
  41. package/workspace/lib/routes-report-design.js +7 -2
  42. package/workspace/lib/routes-report-setups.js +38 -3
  43. package/workspace/package-lock.json +2 -2
  44. package/workspace/package.json +1 -1
  45. package/workspace/premium-vw-meet-test.js +9 -5
  46. package/workspace/public/pm/cozy-evui.js +1 -0
  47. package/workspace/public/pm/cozy-insights.css +34 -0
  48. package/workspace/public/pm/cozy-nav.js +4 -1
  49. package/workspace/public/pm/home-app.js +3 -1
  50. package/workspace/public/pm/premium-insights.js +112 -14
  51. package/workspace/public/pm/premium-vw-guide.css +36 -0
  52. package/workspace/public/pm/premium-vw-guide.js +137 -0
  53. package/workspace/public/pm/premium-vw-open.js +4 -1
  54. package/workspace/public/pm/premium-vw-page.js +2 -1
  55. package/workspace/public/premium-intelligence.html +1 -1
  56. package/workspace/run-tests.js +6 -0
  57. package/workspace/skills/report-biweekly.skill +7 -0
  58. package/workspace/skills/report-daily.skill +7 -0
  59. package/workspace/skills/report-monthly.skill +7 -0
  60. package/workspace/skills/report-special.skill +2 -0
  61. package/workspace/skills/report-template.skill +8 -1
  62. package/workspace/skills/report-weekly.skill +7 -0
  63. package/workspace/skills/report-write.skill +44 -0
  64. package/workspace/test-lists/report-engine.json +5 -0
  65. package/workspace/test-lists/reports-needs-you.json +5 -0
  66. package/workspace/guide/ask-the-agent.md +0 -46
  67. package/workspace/guide/briefing-cards.md +0 -52
  68. package/workspace/guide/briefing.md +0 -159
  69. package/workspace/guide/calendar-popup.md +0 -46
  70. package/workspace/guide/command-palette.md +0 -77
  71. package/workspace/guide/console3-sessions.md +0 -147
  72. package/workspace/guide/document-sheet.md +0 -89
  73. package/workspace/guide/initiative-boards.md +0 -123
  74. package/workspace/guide/initiatives-screen.md +0 -179
  75. package/workspace/guide/initiatives-timeline.md +0 -55
  76. package/workspace/guide/phone-companion.md +0 -89
  77. package/workspace/guide/reports-dashboards.md +0 -70
  78. package/workspace/guide/reports-page.md +0 -87
  79. package/workspace/guide/timeline.md +0 -70
@@ -332,6 +332,40 @@ function pyramidSlideXml(s, L) {
332
332
  let yy = y + (n ? 146 : 0);
333
333
  if (a.more) { text(67, yy, 1146, 60, a.more, { size: 18, color: L.soft }); yy += 70; }
334
334
  text(67, yy, 1146, 26, [[['Who ', { bold: true }], [a.who, {}], ...(a.by ? [[' By ', { bold: true }], [a.by, {}]] : [])]], { size: 18, color: L.soft });
335
+ } else if (s.kind === 'beyond') {
336
+ // the report engine's parts beyond the question: numbered, as the answer's supports
337
+ s.items.forEach((x, i) => {
338
+ const yy = y + i * 104;
339
+ if (i) shape(67, yy - 10, 1146, 1, { fill: L.line });
340
+ shape(67, yy, 35, 35, { geom: 'ellipse', fill: L.accent });
341
+ text(67, yy + 6, 35, 24, String(i + 1), { size: 18, bold: true, color: '#FFFFFF', align: 'ctr' });
342
+ text(121, yy, 1092, 30, x.head, { size: 22, bold: true });
343
+ text(121, yy + 32, 1092, 60, x.text, { size: 17, color: L.soft });
344
+ });
345
+ } else if (s.kind === 'section') {
346
+ // a section of an engine report: paragraphs and lists as text, a table as a grid, bars as shapes. Anything below the footer is cut, never squeezed.
347
+ for (const b of s.blocks || []) {
348
+ if (y > 600) break;
349
+ if (b.kind === 'p') { const h = Math.min(120, 26 * Math.ceil(b.text.length / 110) + 6); text(67, y, 1146, h, b.text, { size: 18, color: L.soft }); y += h + 10; }
350
+ else if (b.kind === 'list') { for (const it of b.items) { if (y > 620) break; text(67, y, 1146, 30, '• ' + it, { size: 18, color: L.soft }); y += 34; } y += 8; }
351
+ else if (b.kind === 'table') {
352
+ const n = b.columns.length; const w = 1146 / n; const rowH = 40;
353
+ b.columns.forEach((c, i) => text(67 + i * w + 6, y, w - 12, 22, c.toUpperCase(), { size: 13, bold: true, color: L.muted }));
354
+ shape(67, y + 26, 1146, 2, { fill: '#D3CCBF' }); y += 34;
355
+ for (const r of b.rows) { if (y > 630) break; r.forEach((c, i) => text(67 + i * w + 6, y + 8, w - 12, 26, c, { size: 15, bold: i === 0 })); shape(67, y + rowH, 1146, 1, { fill: L.line }); y += rowH; }
356
+ y += 12;
357
+ } else if (b.kind === 'bars') {
358
+ for (const r of b.rows) {
359
+ if (y > 630) break;
360
+ text(67, y, 290, 26, r.label, { size: 17 });
361
+ shape(367, y + 4, 700, 18, { geom: 'roundRect', fill: L.track });
362
+ shape(367, y + 4, Math.max(1, 700 * r.share), 18, { geom: 'roundRect', fill: L.accent });
363
+ text(1083, y, 130, 26, r.text, { size: 17, bold: true, align: 'r' });
364
+ y += 36;
365
+ }
366
+ y += 10;
367
+ }
368
+ }
335
369
  } else if (s.kind === 'sources') {
336
370
  text(67, y, 540, 20, 'REACHED', { size: 15, bold: true, color: L.muted });
337
371
  (s.reached.length ? s.reached : ['None']).slice(0, 8).forEach((x, i) => text(67, y + 34 + i * 44, 540, 40, x, { size: 18, color: L.soft }));
@@ -480,7 +514,8 @@ function exportOf(markdown, fmt, opts = {}) {
480
514
  if (!Object.prototype.hasOwnProperty.call(FORMATS, fmt)) return { ok: false, error: `fmt must be one of ${Object.keys(FORMATS).join(', ')}` };
481
515
  const report = parseReport(markdown);
482
516
  let buffer;
483
- try { buffer = fmt === 'pptx' ? pptx(report, require('./report-pyramid.js').deckOf(markdown, opts.look, { data: opts.data || null })) : BUILD[fmt](report); } catch (e) { return { ok: false, error: 'the file could not be built: ' + String((e && e.message) || e).slice(0, 160) }; }
517
+ // opts.deck: the report engine's deck (lib/report-engine.js engineDeckOf), so the PPTX holds the slides the reader shows
518
+ try { buffer = fmt === 'pptx' ? pptx(report, opts.deck || require('./report-pyramid.js').deckOf(markdown, opts.look, { data: opts.data || null })) : BUILD[fmt](report); } catch (e) { return { ok: false, error: 'the file could not be built: ' + String((e && e.message) || e).slice(0, 160) }; }
484
519
  const stem = plain(report.title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'report';
485
520
  return { ok: true, fmt, name: `${stem}.${fmt}`, mime: FORMATS[fmt].mime, buffer };
486
521
  }
@@ -460,6 +460,12 @@ function slideHtml(s, L) {
460
460
  const opts = a.options.map((o, i) => `<div class="ask${i ? ' alt' : ''}" style="flex:1"><div class="tag">Option ${'ABC'[i]}</div><div class="q">${esc(o)}</div></div>`).join('');
461
461
  body = (opts ? `<div style="display:flex;gap:26px">${opts}</div>` : '') + (a.more ? `<p class="sm" style="margin:${opts ? '22px' : '0'} 0 0">${esc(a.more)}</p>` : '')
462
462
  + `<div class="sm" style="display:flex;gap:45px;margin-top:29px"><span><b>Who</b> ${esc(a.who)}</span>${a.by ? `<span><b>By</b> ${esc(a.by)}</span>` : ''}</div>`;
463
+ } else if (s.kind === 'beyond') {
464
+ // the report engine's parts beyond the question (lib/report-engine.js deckOf): each part a numbered support
465
+ body = s.items.map((x, i) => `<div class="sup"><span class="n">${i + 1}</span><div><b>${esc(x.head)}</b><span>${esc(x.text)}</span></div></div>`).join('');
466
+ } else if (s.kind === 'section') {
467
+ // a section of an engine report: any title the writer chose, its blocks as they are (paragraphs, lists, a table, bars of readings)
468
+ body = (s.blocks || []).map((b) => sectionBlockHtml(b, L)).join('');
463
469
  } else if (s.kind === 'sources') {
464
470
  const reached = s.reached.map((x) => `<div class="sup"><span class="sm">${esc(x)}</span></div>`).join('') || '<div class="sup"><span class="sm">None</span></div>';
465
471
  const miss = s.notReached.map((x) => `<div class="sup"><div><b>${esc(x.source)}</b><span>${esc(cap(x.why))}${x.why ? '.' : ''}</span></div></div>`).join('');
@@ -467,6 +473,20 @@ function slideHtml(s, L) {
467
473
  }
468
474
  return open('') + `<div class="in">${head(s.tag)}${body}</div>${footer(s)}</section>`;
469
475
  }
476
+ /** One block of an engine section slide. */
477
+ function sectionBlockHtml(b, L) {
478
+ if (b.kind === 'p') return `<p class="sm" style="margin:0 0 14px;max-width:1120px">${esc(b.text)}</p>`;
479
+ if (b.kind === 'list') return `<div style="margin:0 0 10px">${b.items.map((x) => `<div class="sup" style="padding:9px 0"><span class="sm">${esc(x)}</span></div>`).join('')}</div>`;
480
+ if (b.kind === 'table') {
481
+ return `<div class="tb" style="grid-template-columns:repeat(${b.columns.length},minmax(0,1fr));font-size:16px;margin:0 0 14px;align-items:stretch">${b.columns.map((c) => `<div class="h">${esc(c)}</div>`).join('')}`
482
+ + b.rows.map((r) => r.map((c, i) => `<div${i ? '' : ' style="font-weight:800"'}>${esc(c)}</div>`).join('')).join('') + '</div>';
483
+ }
484
+ if (b.kind === 'bars') {
485
+ return `<div style="display:flex;flex-direction:column;gap:12px;margin:0 0 14px">${b.rows.map((r) => `<div style="display:flex;align-items:center;gap:16px;font-size:18px"><span style="width:300px;flex:none">${esc(r.label)}</span>`
486
+ + `<span style="flex:1;height:20px;background:${L.track};border-radius:4px;overflow:hidden"><i style="display:block;height:100%;width:${Math.round(r.share * 1000) / 10}%;background:${L.accent}"></i></span><b style="width:120px;flex:none;text-align:right">${esc(r.text)}</b></div>`).join('')}</div>`;
487
+ }
488
+ return '';
489
+ }
470
490
  /** The deck as one HTML string (the viewer's frames and the PDF print). */
471
491
  function deckHtml(deck) {
472
492
  if (!deck) return '';
@@ -34,14 +34,19 @@ async function via(mod, pathAndQuery, req, ctx) {
34
34
  }
35
35
  /** The opening sentence of each listed report file (insights-one-list L6), read from the workspace's repository. A file that cannot be read has none. */
36
36
  const LEADS_MAX = 60;
37
- function leadsOf(ctx, ws, list) {
37
+ function leadsOf(ctx, ws, list, shas) {
38
38
  const out = {};
39
39
  let root = null;
40
40
  try { root = require('path').resolve(ctx.getWs(ws).root); } catch { return out; }
41
41
  for (const r of list.slice().sort((a, b) => String(b.slot).localeCompare(String(a.slot))).slice(0, LEADS_MAX)) {
42
42
  const rel = String(r.path || '');
43
43
  if (!rel || rel.includes('..') || require('path').isAbsolute(rel)) continue;
44
- try { out[rel] = Insights.leadOf(require('fs').readFileSync(require('path').join(root, rel), 'utf8').slice(0, 20000)); } catch { /* an unreadable file has no lead */ }
44
+ try {
45
+ const buf = require('fs').readFileSync(require('path').join(root, rel));
46
+ // Reports and dashboards redesign R: the headline is the preview, never the line that says the definition is a draft
47
+ out[rel] = require('./report-acts.js').leadPastWarning(buf.toString('utf8').slice(0, 20000), Insights.leadOf);
48
+ if (shas) shas[rel] = require('crypto').createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex'); // what Issue sends: the file as read
49
+ } catch { /* an unreadable file has no lead */ }
45
50
  }
46
51
  return out;
47
52
  }
@@ -96,7 +101,11 @@ module.exports = {
96
101
  const su = await via(setups, '/api/report-setups' + q, req, ctx).catch(() => ({ status: 500, body: null }));
97
102
  const setupsOk = su.status === 200 && !!su.body && Array.isArray(su.body.setups);
98
103
  if (!setupsOk) missing.push('report setups');
99
- const leads = reportsOk ? leadsOf(ctx, ws, rp.body.reports || []) : {};
104
+ const shas = {};
105
+ const leads = reportsOk ? leadsOf(ctx, ws, rp.body.reports || [], shas) : {};
106
+ // Reports and dashboards redesign R: each report's definition (its title names it; status: draft is "Confirm the definition")
107
+ let defs = [];
108
+ if (pick) { try { defs = require('./report-setups.js').definitionsOf(require('path').resolve(ctx.getWs(pick.ws).root)).map((d) => ({ id: d.id, title: d.title, status: d.status })); } catch { defs = []; } }
100
109
  // premium-agreed-design (G1, G2): the board's initiatives, so a goal can name what moves it and Outcomes can list shipped work. Read-only.
101
110
  let initiatives = null;
102
111
  if (pick && pick.id) {
@@ -137,6 +146,11 @@ module.exports = {
137
146
  outcomes, reports: reportsOk ? rp.body.reports || [] : [], reportsOk, missing, saved, mine,
138
147
  setups: setupsOk ? su.body : null, pinned, leads, archived, initiatives, goalMeta, ins, budgets, okr, promises, self: isSelf(ctx, pick),
139
148
  });
149
+ // Reports and dashboards redesign R (RgR1Panel): each waiting report's one ✓, its display name, and the other product it is about (lib/report-acts.js)
150
+ if (model && model.reports) {
151
+ const R = require('./report-acts.js').reportActsOf({ rows: model.reports.rows || [], setups: model.reports.setups || [], defs, boards: (model.board && model.board.switcher) || [], here: model.board ? model.board.ws : null, shas });
152
+ Object.assign(model.reports, { acts: R.acts, names: R.names, about: R.about });
153
+ }
140
154
  // batch 16 item 1: each ✦ strip finding about a goal that is behind carries one drafted move (lib/insight-moves.js), done ones marked
141
155
  if (pick) {
142
156
  try {
@@ -225,7 +225,14 @@ async function dashboardAnswer(req, ctx, w, scope, from) {
225
225
  }
226
226
  if (!X.model) X.f.goalDraft = null;
227
227
  else if (!RR.goalsOf(X.f.intel, scope, Date.parse(X.f.today + 'T00:00:00Z'), false).goals.length) X.f.goalDraft = { title: '', sub: '', anchor: scope };
228
- return { status: 200, body: { ok: true, dashboard: RR.dashboardOf(X.f) } };
228
+ const d = RR.dashboardOf(X.f);
229
+ // Reports and dashboards redesign R (RgR4Make): a product page's ✦ line is Intelligence's line for that product (lib/report-acts.js). A held
230
+ // source keeps its own warning; "ready to send" follows only in a week with news (something behind, a ticket closed, an update drafted)
231
+ if (d && d.level === 'product' && !d.heldBy) {
232
+ const R = d.reports || [], news = (d.goals || []).some((g) => g.dot === 'behind') || !!d.done || R.some((r) => r.kind === 'weekly' && r.state === 'drafted' && !r.desk);
233
+ d.handled = require('./report-acts.js').productLineOfIntel(X.f.intel, { deskReady: R.some((r) => r.kind === 'weekly' && r.state === 'drafted' && r.desk), news, fallback: d.handled });
234
+ }
235
+ return { status: 200, body: { ok: true, dashboard: d } };
229
236
  }
230
237
 
231
238
  /** The scopes of a workspace: its product, its programs (the ones whose charter is here) and its live initiatives. */
@@ -430,7 +437,9 @@ async function tickScope(ctx, w, s, now, cache = null) {
430
437
  const e = ensureSetup(w, s.scope, m.kind, s.name, { now });
431
438
  if (!e.ok) { out.push({ scope: s.scope, kind: m.kind, ok: false, error: e.error }); continue; }
432
439
  const r = await SR.draftIntelligence(ctx, w, e.setup, m.slot, { model: X.model, now, ready: extras(X) });
433
- if (r.ok || r.existing) RR.append(RR.ledgerFile(stateDir(w)), { ev: 'moment', key, slot: m.slot, why: m.why }, { now });
440
+ if (r.ok || r.existing || r.quiet) RR.append(RR.ledgerFile(stateDir(w)), { ev: 'moment', key, slot: m.slot, why: m.why }, { now });
441
+ // nothing to report at this moment: one quiet line (Your day says it), no draft to grade
442
+ if (r.quiet) RR.append(RR.ledgerFile(stateDir(w)), { ev: 'quiet', key, slot: m.slot }, { now });
434
443
  out.push({ scope: s.scope, kind: m.kind, slot: m.slot, why: m.why, ...r });
435
444
  }
436
445
  // a heads-up: a broken health budget or a critical slip, at once and outside the schedule (PbG3)
@@ -49,6 +49,7 @@ const RS = require('./report-setups.js');
49
49
  const RC = require('./report-checks.js');
50
50
  const RDraft = require('./report-draft.js');
51
51
  const RP = require('./report-pyramid.js');
52
+ const RE = require('./report-engine.js');
52
53
  const headless = require('./headless.js');
53
54
 
54
55
  const text = (v) => (typeof v === 'string' ? v : '');
@@ -293,6 +294,8 @@ function startCycle(ctx, w, cadence, rel) {
293
294
  const st = D.stateOf(w.root, cadence);
294
295
  if (!st.saved) return { ok: false, error: 'no saved design' };
295
296
  if (D.isIssuedSlot(w.root, cadence, c.slot)) return { ok: false, error: 'issued' };
297
+ // the report engine draws its own deck from report.json; the saved design is its theme, so no per-cycle render rewrites its words
298
+ if (RE.engineReportOf(c.md)) return { ok: false, error: 'drawn by the report engine' };
296
299
  const key = keyOf(w.root, cadence);
297
300
  if (jobs.has(key)) {
298
301
  const j = jobs.get(key);
@@ -436,7 +439,8 @@ function cycleAnswer(w, c) {
436
439
  const differences = rec && Array.isArray(rec.differences) ? rec.differences : [];
437
440
  // designed: THIS cycle has a render drawn to a saved design (the contract's answer is per document). A cycle drafted before the design,
438
441
  // or one whose first render is still running, is not designed yet: the page reads it in its format view and fetches no cycle-html.
439
- return { ok: true, designed: !!st.saved && !!rec, design_version: rec ? rec.design_version : null,
442
+ const engine = !!RE.engineReportOf(c.md);
443
+ return { ok: true, designed: !engine && !!st.saved && !!rec, engine, design_version: rec ? rec.design_version : null,
440
444
  format: (rec && rec.format) || (st.saved && (D.versionMeta(w.root, c.cadence, st.saved.version) || {}).format) || st.format || 'deck',
441
445
  outline: rec && Array.isArray(rec.outline) ? rec.outline : [], differences, decisions: rec && Array.isArray(rec.decisions) ? rec.decisions : [],
442
446
  matches: !!rec && differences.length === 0, rendering: !!(j && j.slotPath === c.rel), previous: prev ? { path: prev.path, slot: prev.slot } : null };
@@ -482,7 +486,8 @@ async function handle(req, res, url, ctx) {
482
486
  // The finished slides of a cadence draft (canvas RsA): the Pyramid deck in the report's saved look, else the standard PMOS look.
483
487
  const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
484
488
  let md; try { md = fs.readFileSync(d.c.md, 'utf8'); } catch { return send(bad(404, 'no such report document')); }
485
- const deck = RP.deckOf(md, RP.lookOf(w.root, d.c.cadence), { data: RP.dataOf(d.c.md) });
489
+ // a draft the report engine wrote is drawn from its own model (report.json), in the report's look: the reader, the PDF and the PPTX are one deck
490
+ const deck = RE.engineDeckOf(d.c.md, RP.lookOf(w.root, d.c.cadence)) || RP.deckOf(md, RP.lookOf(w.root, d.c.cadence), { data: RP.dataOf(d.c.md) });
486
491
  if (!deck) return send(bad(404, 'this document is not a cadence draft, so it has no Pyramid deck'));
487
492
  return sendHtml(res, D.sanitize(RP.deckHtml(deck)));
488
493
  }
@@ -17,6 +17,9 @@
17
17
  * POST /api/report-setups/pause | resume { ws, id } → { ok, setup }
18
18
  * POST /api/report-setups/edit { ws, id, schedule?, name?, audience?, output? } → { ok, setup } change a TEMPLATE (premium-reports-from-chat L1).
19
19
  * schedule { cadence: 'off' } pauses it; any other schedule changes it and resumes it
20
+ * POST /api/report-setups/confirm { ws, id } → { ok, path, sha } ✓ Confirm the definition: status: draft → stable (redesign R)
21
+ * POST /api/report-setups/move { ws, id, to } → { ok, id, to, moved } ✦ Move to <product>: definition, drafts and setup go to
22
+ * the workspace `to`; a report with an issued issue is 409 (redesign R)
20
23
  * POST /api/report-setups/remove { ws, id } → { ok } delete a TEMPLATE: its schedule stops; its drafts and issued reports stay (L2)
21
24
  * POST /api/report-setups/from-dashboard { ws, key, name, output, schedule, audience } → { ok, setup }
22
25
  * POST /api/report-setups/finder { ws, cadence?, sources? } → { ok, finder }
@@ -62,7 +65,7 @@ const headless = require('./headless.js');
62
65
 
63
66
  const text = (v) => (typeof v === 'string' ? v : '');
64
67
  const bad = (status, error) => ({ status, body: { ok: false, error } });
65
- const POSTS = ['ask', 'change', 'cancel', 'setup', 'draft', 'dismiss', 'pause', 'resume', 'from-dashboard', 'finder', 'edit', 'remove', 'documents/discard', 'documents/archive', 'strip-dismiss'];
68
+ const POSTS = ['ask', 'change', 'cancel', 'setup', 'draft', 'dismiss', 'pause', 'resume', 'from-dashboard', 'finder', 'edit', 'remove', 'documents/discard', 'documents/archive', 'strip-dismiss', 'confirm', 'move'];
66
69
  const IR = require('./intel-report.js');
67
70
  const MADE_BY = ['schedule', 'you', 'agent'];
68
71
 
@@ -251,7 +254,10 @@ async function tick(ctx, now = new Date()) {
251
254
  if (s.drafted.some((d) => d.slot === slot)) continue;
252
255
  let out;
253
256
  if (IR.isIntelligence(s.source)) {
257
+ const qk = `${w.root}|${s.id}|${slot}`;
258
+ if (quietSlots.has(qk)) continue; // nothing to report at this slot already: not read again every tick
254
259
  try { out = await draftIntelligence({ ...ctx, getWs: (id) => (id === w.id || id == null ? w : ctx.getWs(id)) }, w, s, slot, { now }); } catch (e) { out = { ok: false, error: String((e && e.message) || e) }; }
260
+ if (out && out.quiet) quietSlots.add(qk);
255
261
  done.push({ id: s.id, slot, ...out });
256
262
  continue;
257
263
  }
@@ -290,7 +296,9 @@ function exportAnswer(ctx, res, w, url) {
290
296
  } catch { return bad(404, 'no such document'); }
291
297
  const cyc = /(?:^|\/)reports\/([a-z][a-z0-9-]{0,39})\/\d{4}-\d{2}-\d{2}\.md$/.exec(rel);
292
298
  const RPy = require('./report-pyramid.js');
293
- const out = RX.exportOf(md, fmt, fmt === 'pptx' && cyc ? { look: RPy.lookOf(abs, cyc[1]), data: RPy.dataOf(full) } : {});
299
+ const look = fmt === 'pptx' && cyc ? RPy.lookOf(abs, cyc[1]) : null;
300
+ const deck = look ? require('./report-engine.js').engineDeckOf(full, look) : null;
301
+ const out = RX.exportOf(md, fmt, fmt === 'pptx' && cyc ? { look, data: RPy.dataOf(full), ...(deck ? { deck } : {}) } : {});
294
302
  if (!out.ok) return bad(500, out.error);
295
303
  return { status: 200, file: out };
296
304
  }
@@ -508,6 +516,23 @@ async function handle(req, res, url, ctx) {
508
516
  const now = new Date();
509
517
  return send({ status: 200, body: { ok: true, setup: setupOut(RS.fold(RS.readLines(file)).setups.get(id), now, documentsOf(ctx, w), copyIndex(root), root) } });
510
518
  }
519
+ if (sub === 'confirm') {
520
+ // Reports and dashboards redesign R: ✓ Confirm the definition (status: draft → stable). Only the PM's press confirms one (report-daily.skill)
521
+ const r = require('./report-acts.js').confirmDefinition(root, text(body.id), opts);
522
+ if (!r.ok) return fail(r);
523
+ if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg();
524
+ return send({ status: 200, body: r });
525
+ }
526
+ if (sub === 'move') {
527
+ // Reports and dashboards redesign R: ✦ Move to <product>. The target is a workspace this server knows; an issued report is never moved (409)
528
+ const t = wsOf(ctx, text(body.to));
529
+ if (!t || !text(body.to)) return send(bad(404, 'no such product to move it to'));
530
+ if (t.id === w.id) return send(bad(400, 'it is in that product already'));
531
+ const r = require('./report-acts.js').moveReportTo(root, text(body.id), t.root, Object.assign({}, opts, { toWsId: t.id }));
532
+ if (!r.ok) return fail(r);
533
+ if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg();
534
+ return send({ status: 200, body: Object.assign({ to: t.id }, r) });
535
+ }
511
536
  if (sub === 'remove') {
512
537
  const r = RS.remove(root, text(body.id), opts);
513
538
  if (!r.ok) return fail(r);
@@ -543,6 +568,8 @@ async function draftIntelligence(ctx, w, s, slot, { model, now = new Date(), rea
543
568
  try { if (fs.statSync(path.join(path.resolve(w.root), rel)).isFile()) return { ok: false, existing: true, slot, path: rel, error: 'drafted already' }; } catch { /* not there yet */ }
544
569
  const m = model || await intelModel(null, ctx, w.id, null);
545
570
  if (!m) return { ok: false, error: 'Intelligence could not be read' };
571
+ // no draft without a finding (design R item 2, report engine E step 3): a scope with no reading, no verdict and no read budget has nothing to report
572
+ if (!hasFinding(m, s)) return { ok: false, quiet: true, status: 422, slot, error: 'nothing to report: no goal here has a reading yet' };
546
573
  const mine = (RN.reportsOf(w.root).reports || []).filter((x) => x.cadence === s.id && x.slot < slot).sort((a, b) => (a.slot < b.slot ? -1 : 1));
547
574
  const before = mine.filter((x) => x.issued).pop() || null;
548
575
  let prior = null;
@@ -552,6 +579,14 @@ async function draftIntelligence(ctx, w, s, slot, { model, now = new Date(), rea
552
579
  return r.ok ? { ok: true, slot, path: r.path } : { ok: false, status: r.status, error: r.error };
553
580
  }
554
581
 
582
+ /** The slots of Intelligence reports that had nothing to report, in this process: the tick does not read them again. */
583
+ const quietSlots = new Set();
584
+ /** True when Intelligence holds something to report for the setup's scope: a reading on a goal or budget, or a recorded verdict. */
585
+ function hasFinding(model, setup) {
586
+ const scope = IR.scopeOf(setup && setup.source) || 'view';
587
+ const sl = IR.storylineOf(model, { scope });
588
+ return sl.items.some((it) => it.kind === 'scorecard' || it.kind === 'exhibit' || it.kind === 'verdict');
589
+ }
555
590
  /** Draft a report's cycle now through the one writer (report-draft.js writeDraft). → { slot, path } | { error } */
556
591
  function draftCycle(root, cadence, slot, actor) {
557
592
  const d = RD.draftOf(root, { cadence, slot: slot || undefined });
@@ -567,4 +602,4 @@ function firstDraft(root, r, actor) {
567
602
  return draftCycle(root, r.setup.id, r.first_draft && r.first_draft.slot, actor);
568
603
  }
569
604
 
570
- module.exports = { prefixes: ['/api/report-setups', '/api/report-documents', '/api/report-export'], handle, tick, ensureMigrated, documentsOf, listAnswer, copyOf, draftIntelligence, intelModel };
605
+ module.exports = { prefixes: ['/api/report-setups', '/api/report-documents', '/api/report-export'], handle, tick, ensureMigrated, documentsOf, listAnswer, copyOf, draftIntelligence, intelModel, hasFinding };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.515.0",
3
+ "version": "0.517.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.515.0",
9
+ "version": "0.517.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.515.0",
3
+ "version": "0.517.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -39,6 +39,10 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
39
39
  srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
40
40
  await T.waitForServer(BASE);
41
41
  browser = await chromium.launch({ executablePath: exe });
42
+ /* One clock for the page and the stand-ins: today at 11:00, so "Rail review" (30 minutes on) is today at any hour the suite runs.
43
+ * Unpinned, after 23:30 it fell on tomorrow and S6 lost its Record. */
44
+ const NOW = new Date(); NOW.setHours(11, 0, 0, 0);
45
+ const open = (u, o) => F.open(browser, BASE, u, { now: NOW, ...o });
42
46
  const body = (p) => p.evaluate(() => (document.querySelector('#czViewer .cz-vw-body') || {}).innerText || '');
43
47
  const press = (p, sel) => p.$eval('#czViewer ' + sel, (b) => b.click());
44
48
  const folds = (p) => p.evaluate(() => [...document.querySelectorAll('#czViewer [data-czs-fold]')].map((x) => x.dataset.czsFold + (x.getAttribute('aria-expanded') === 'true' ? '*' : '')).join());
@@ -46,7 +50,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
46
50
  const sum = (p) => p.evaluate(() => { const s = [...document.querySelectorAll('#czViewer .czs-sum')].find((x) => x.offsetParent) || document.querySelector('#czViewer .czs-sum'); return s ? { line: (s.querySelector('.czs-line') || {}).innerText || '', text: (s.querySelector('.czs-p') || {}).innerText || '', meta: (s.querySelector('.czs-meta') || {}).innerText || '', pri: (s.querySelector('[data-czs-act]') || {}).textContent || '' } : null; });
47
51
 
48
52
  /* an ended meeting with notes and a recording */
49
- const A = await F.open(browser, BASE, '/premium-viewer.html?open=meeting&note=n1');
53
+ const A = await open('/premium-viewer.html?open=meeting&note=n1');
50
54
  const p = A.page;
51
55
  const up = await until(() => p.evaluate(() => !!document.querySelector('#czViewer .vz-mtg[data-mk="notes"]') && !!document.querySelector('#czViewer .czs-sum')), 15000);
52
56
  ok('0 the meeting opens in the Viewer on its own page', up);
@@ -128,7 +132,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
128
132
  await A.ctx.close();
129
133
 
130
134
  /* S6 Before: a meeting to come, no note */
131
- const B = await F.open(browser, BASE, '/premium-viewer.html?open=meeting&event=ev-rail&date=' + F.dayKey(F.world().RAIL), { scene: 'prep' });
135
+ const B = await open('/premium-viewer.html?open=meeting&event=ev-rail&date=' + F.dayKey(F.world(NOW).RAIL), { scene: 'prep' });
132
136
  const q = B.page;
133
137
  const prepUp = await until(() => q.evaluate(() => !!document.querySelector('#czViewer .vz-mtg[data-mk="prep"]') && !!document.querySelector('#czViewer .czs-sum')), 15000);
134
138
  const s6 = await sum(q);
@@ -169,7 +173,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
169
173
  await B.ctx.close();
170
174
 
171
175
  /* S7 During: a note that records */
172
- const C = await F.open(browser, BASE, '/premium-viewer.html?open=meeting&note=n2', { init: () => { window.__premiumHolding = { id: 'n2' }; } });
176
+ const C = await open('/premium-viewer.html?open=meeting&note=n2', { init: () => { window.__premiumHolding = { id: 'n2' }; } });
173
177
  const r = C.page;
174
178
  const liveUp = await until(() => r.evaluate(() => !!document.querySelector('#czViewer .vz-mtg[data-mk="live"]') && !!document.querySelector('#czViewer .czs-sum')), 15000);
175
179
  const s7 = await sum(r);
@@ -196,7 +200,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
196
200
  await C.ctx.close();
197
201
 
198
202
  /* S10 a note that is not there */
199
- const D = await F.open(browser, BASE, '/premium-viewer.html?open=meeting&note=nope');
203
+ const D = await open('/premium-viewer.html?open=meeting&note=nope');
200
204
  ok('S10 a note that is not there says so, and the card still opens', await until(() => D.page.evaluate(() => !!document.querySelector('#czViewer') && /Untitled note|could not|not/.test(document.querySelector('#czViewer').innerText))));
201
205
  await D.ctx.close();
202
206
 
@@ -204,7 +208,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
204
208
  // the meeting's own presses: the summary's (the shell's primary button is the shell's) and the folds; a press may keep its drawn size and take a 44 px hit area from ::before
205
209
  const HITS = () => { window.hits = () => [...document.querySelectorAll('#czViewer .czs-sum button:not(.czs-pri), #czViewer [data-czs-fold]')].filter((b) => { if (!b.getClientRects().length) return false; const pb = getComputedStyle(b, '::before'), hit = pb.content !== 'none' && pb.position === 'absolute' ? parseFloat(pb.height) || 0 : 0; return Math.max(b.getBoundingClientRect().height, hit) < 44; }).map((b) => (b.dataset.a || b.dataset.czsFold || b.className) + ':' + Math.round(b.getBoundingClientRect().height)); };
206
210
  for (const [u, o] of [['/premium-viewer.html?open=meeting&note=n1', {}], ['/premium-viewer.html?open=meeting&note=n2', { init: () => { window.__premiumHolding = { id: 'n2' }; } }]]) {
207
- const E = await F.open(browser, BASE, u, { w: 390, h: 844, ...o });
211
+ const E = await open(u, { w: 390, h: 844, ...o });
208
212
  await E.page.evaluate(HITS);
209
213
  await until(() => E.page.evaluate(() => !!document.querySelector('#czViewer .vz-mtg') && !!document.querySelector('#czViewer .czs-sum')), 15000);
210
214
  const fl = await until(async () => { const v = await E.page.evaluate(() => ({ over: document.documentElement.scrollWidth - innerWidth, full: (() => { const r = document.querySelector('#czViewer').getBoundingClientRect(); return r.left === 0 && Math.round(r.width) === innerWidth; })(),
@@ -23,6 +23,7 @@ export const ICON = {
23
23
  refresh: 'M4 12a8 8 0 0 1 14-5.3M20 4v4h-4M20 12a8 8 0 0 1-14 5.3M4 20v-4h4', jira: 'M4 4h16v16H4zM8.5 12.5l2.5 2.5 4.5-5', cal: 'M4 5h16v15H4zM4 10h16M8 3v4M16 3v4',
24
24
  repo: 'M6 3h12v18l-6-4-6 4z', mic: 'M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3zM5 11a7 7 0 0 0 14 0M12 18v3', tools: 'M3 6h18v12H3zM3 7l9 6 9-6',
25
25
  analytics: 'M4 20V10M10 20V4M16 20v-8M22 20H2', check: 'M5 12l4.5 4.5L19 7',
26
+ guide: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6M12 17h.01',
26
27
  };
27
28
  export function toast(t) { const el = typeof document !== 'undefined' && document.getElementById('toast'); if (!el) return; el.textContent = t; el.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => el.classList.remove('on'), 4200); }
28
29
  export const wsParam = () => (typeof location !== 'undefined' ? new URLSearchParams(location.search).get('ws') || '' : '');
@@ -955,3 +955,37 @@ body.ins-trv #pGoals .ins-trstrip { display: flex; } /* the narrow form hides
955
955
  .ins-row.ins-pcols { grid-template-columns: minmax(0, 1fr); row-gap: 6px; border: 1px solid var(--cz-hair); border-radius: 18px; background: var(--cz-card); padding: 12px 14px; }
956
956
  .ins-allp .ins-th { display: none; }
957
957
  }
958
+
959
+ /* Reports and dashboards redesign R (RgR1Panel, RgR3Dash): Needs you rows with one ✓ each, the two ready-made folds, the drafted first dashboard */
960
+ .ins-nlist { flex: none; display: flex; flex-direction: column; border: 1px solid var(--cz-hair); border-radius: 16px; overflow: hidden; margin: 4px 0 8px; }
961
+ .ins-nrow { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-top: 1px solid var(--cz-hair); }
962
+ .ins-nrow:first-child { border-top: 0; }
963
+ .ins-nrow.sel { background: var(--cz-bluebg); }
964
+ .ins-nrow .nm { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; border: 0; background: none; padding: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; }
965
+ .ins-nrow .nm b { font-size: var(--fs-body); font-weight: var(--fw-strong); line-height: var(--lh-body); }
966
+ .ins-nrow .nm small { font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
967
+ .ins-nrow .hl { font-size: var(--fs-meta); color: var(--cz-ink2); line-height: var(--lh-meta); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
968
+ .ins-nrow .ac { flex: none; display: flex; align-items: center; gap: 8px; }
969
+ .ins-nrow .ac .note { white-space: nowrap; }
970
+ @media (hover: hover) { .ins-nrow:not(.sel):hover { background: rgba(244, 243, 240, .5); } }
971
+ .ins-mlist { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-meta); line-height: var(--lh-meta); }
972
+ .ins-libfold[open] .ins-mlist { display: none; }
973
+ .ins-libfold:not([open]) .ins-libin { display: none; }
974
+ .ins-ddraft { grid-column: 1 / -1; cursor: default; }
975
+ .ins-dtiles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
976
+ .ins-dtile { position: relative; display: flex; flex-direction: column; gap: 2px; border: 1px solid var(--cz-hair); border-radius: 14px; padding: 10px 12px; min-width: 0; }
977
+ .ins-dtile.w { border-style: dashed; border-color: var(--cz-hair2, rgba(42,46,53,.14)); }
978
+ .ins-dtile .lb { font-size: var(--fs-meta); color: var(--cz-ink2); line-height: var(--lh-meta); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
979
+ .ins-dtile .kr { color: var(--cz-ink3); margin-right: 6px; }
980
+ .ins-dtile .big { font-size: var(--fs-panel); font-weight: var(--fw-strong); line-height: var(--lh-panel, 1.3); }
981
+ .ins-dtile .ft { display: flex; align-items: center; gap: 6px; justify-content: space-between; }
982
+ .ins-dtile .ft .cz-ib { flex: none; }
983
+ .ins-tdot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; background: var(--cz-dot-q); }
984
+ .ins-tdot.ok { background: var(--cz-dot-ok); } .ins-tdot.risk { background: var(--cz-dot-risk); } .ins-tdot.late { background: var(--cz-dot-late); }
985
+ .ins-dact { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
986
+ @media (max-width: 760px) {
987
+ .ins-nlist { border: 0; gap: 8px; overflow: visible; }
988
+ .ins-nrow { border: 1px solid var(--cz-hair); border-radius: 16px; background: var(--cz-card); }
989
+ .ins-nrow:first-child { border-top: 1px solid var(--cz-hair); }
990
+ .ins-nrow .ac .note { display: none; }
991
+ }
@@ -3,12 +3,13 @@
3
3
  * item 5, canvas FmA1Menu and FmA4Desk: fewer menus).
4
4
  *
5
5
  * One button, bottom left on desktop and bottom right on a phone, opens the menu: the three places, Home, Board and Intelligence; then a
6
- * tray of two icons, Search (⌘K on a desk) and Settings. It is the one way between places, and it holds nothing else: New for you is a fold
6
+ * tray of three icons, Search (⌘K on a desk), Guide and Settings. It is the one way between places, and it holds nothing else: New for you is a fold
7
7
  * in Your day, Sources are Settings › Connectors, Theme is Settings › General, and More is gone (feedback batch 16 item 5).
8
8
  * The button keeps its two dots on every page: blue when something is new for you, amber when a source is stale. Open, the blue dot sits on
9
9
  * the Home row (where Your day is) and the amber dot on the Settings icon; the button's own name says both ("Open the menu. 3 new for you.
10
10
  * Jira is stale.").
11
11
  * Search pm/cozy-search.js: across the repository and every worktree; Ctrl K (Cmd K) or "/" opens it from any page
12
+ * Guide pm/premium-vw-guide.js: the user guide's Start here, in the Viewer of the page you are on (initiative guide-in-app)
12
13
  * Settings pm/cozy-settings.js; it opens at Connectors while a source is stale, so the stale one is the first thing there
13
14
  * A page that passes opts.here() runs it when its own place is pressed, in place of a reload (Home: Menu > Home closes the page that is open).
14
15
  * The counts come from GET /api/news-for-you and GET /api/sources, read when the page opens and every two minutes while it is shown; each
@@ -55,6 +56,7 @@ export function menuHtml(here, o = {}) {
55
56
  }).join('')
56
57
  + '<div class="ev-tray">'
57
58
  + `<button type="button" class="ev-tb" data-cz-search aria-label="Search (⌘K)" title="Search ⌘K">${svg(ICON.search, 20)}</button>`
59
+ + `<button type="button" class="ev-tb" data-cz-guide aria-label="Guide" title="Guide">${svg(ICON.guide, 20)}</button>`
58
60
  + `<button type="button" class="ev-tb" data-cz-settings aria-label="Settings${o.stale ? '. ' + esc(o.stale.text) : ''}" title="Settings">${svg(ICON.settings, 20)}${o.stale ? dot('risk') : ''}</button>`
59
61
  + '</div>';
60
62
  }
@@ -116,6 +118,7 @@ export function mountNav(here, doc = document, opts = {}) {
116
118
  set(false);
117
119
  if ('czSearch' in b.dataset) import('./cozy-search.js').then((M) => M.openSearch(opts.search ? { go: opts.search() } : {}));
118
120
  // a stale source opens Settings at Connectors, the stale one first (FmA3Settings: open where it matters)
121
+ else if ('czGuide' in b.dataset) import('./premium-vw-open.js').then((M) => M.openGuide('index'));
119
122
  else if ('czSettings' in b.dataset) import('./cozy-settings.js').then((M) => M.openSettings(N.stale ? 'connectors' : undefined));
120
123
  });
121
124
  // Ctrl K (Cmd K) and "/" open Search from any page; a second press focuses it
@@ -986,7 +986,9 @@ function productPageHtml(x, ws, vid, name, g) {
986
986
  const tRow = (t) => row(`data-hp="ini" data-k="${esc(t.ini)}"`, '', t.name, t.of, t.late && t.due ? `<span class="pv-late">${esc(lateDays(t.due) + ' days late')}</span>` : '');
987
987
  const U = own ? H.weeklyUpdate(S.D, S.D.now) : null, sent = U && (U.sent || []).some((y) => y.week === U.week.iso);
988
988
  const R = d ? DB.reportsHtml(d, { open: x.open }) : '';
989
- const extra = own ? (R ? '' : row('data-hp="update"', '', 'Weekly update', sent ? 'Sent this week' : 'Ready to send'))
989
+ // redesign R (RgR4Make): "Ready to send" only in a week with news (something at risk or waiting for a decision); a quiet week says so
990
+ const news = U && U.sections.some(([k, , L]) => k !== 'ok' && L.length);
991
+ const extra = own ? (R ? '' : row('data-hp="update"', '', 'Weekly update', sent ? 'Sent this week' : news ? 'Ready to send' : 'Nothing new this week · skipped'))
990
992
  : `<a class="pv-row" href="${esc(ownHome)}" target="_blank" rel="noopener"><span class="pv-rt"><b>Weekly update</b><small>In ${esc(name)}'s own Home, in a new tab</small></span></a>`;
991
993
  const reps = ((d && d.reports) || []).filter((r) => r.kind !== 'heads-up');
992
994
  const tabs = [