mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

render.ts

Language TypeScript23.7 KB519 lines

apps/dashboard/src/render.ts

  1. /**
  2. * Assemble the surfaces into one self-contained `dashboard.html`.
  3. *
  4. * One file with tabs rather than six pages. The mockup lays the surfaces out side by side because that is how a design
  5. * canvas works. In use they are one artifact: the same 6:00 AM snapshot, read
  6. * five different ways. Six files would mean six chances for one of them to be
  7. * yesterday's, and "the briefing says 146 but the board says 151" destroys trust
  8. * in both faster than any missing feature.
  9. *
  10. * So: one snapshot, one file, tabs. It opens from disk with no server, survives
  11. * being emailed, and prints.
  12. *
  13. * The page is usable without JavaScript. Tabs, drill-downs and the person picker
  14. * are progressive enhancement: with scripting off, every surface is simply
  15. * visible in sequence. A briefing that goes blank in a locked-down browser at
  16. * 9:10 is worse than one that scrolls.
  17. */
  18. import type { Headline, Snapshot } from './collect.js'
  19. import { MERIDIAN_TZ, localDay } from '@meridian/ninety-client'
  20. import { esc, join, map } from './html.js'
  21. import { THEME_CSS, QUEUE_CSS } from './theme.js'
  22. import { canReadInbox } from '../../../config/inbox.js'
  23. import { hasOnboarding } from '../../../config/onboarding.js'
  24. import { buildViewModel } from './view-model.js'
  25. import type { HistoryRow } from './history.js'
  26. import type { Briefings } from './briefings.js'
  27. import { COMPANY_VIEWER, type Viewer } from './viewer.js'
  28. import {
  29. board,
  30. legendStrip,
  31. briefingData,
  32. briefingProse,
  33. dataHealth,
  34. elsewhere,
  35. inlineBriefing,
  36. myDaySurface,
  37. onboardingSurface,
  38. queueSurface,
  39. writtenBriefing,
  40. } from './surfaces.js'
  41. /**
  42. * The tab bar for a route page (the inbox): identical bar, but every tab is a
  43. * link into the board's hash-deep-links, and the current route is marked with
  44. * aria-current, so the inbox reads as part of the board. A "back" link is
  45. * navigation debt, not navigation.
  46. */
  47. export function routeTabBar(current: 'inbox' | 'none', showOnboarding = false): string {
  48. const link = (id: string, label: string): string =>
  49. `<a class="tab" href="/#${id}">${esc(label)}</a>`
  50. return `<div class="tabs" role="navigation" aria-label="Board">
  51. ${link('board', 'Open work')}
  52. ${
  53. // Same rule as the board's own bar: omitted, not disabled. A link into a
  54. // surface the board will not render for this viewer is a link to the
  55. // first tab, which reads as a broken one.
  56. showOnboarding ? link('onboarding', 'Onboarding') : ''
  57. }
  58. ${link('my-day', 'My day')}
  59. ${link('queue', 'The queue')}
  60. <a class="tab" href="/inbox" aria-current="${current === 'inbox' ? 'page' : 'false'}">Inbox</a>
  61. ${link('briefing', 'Briefing')}
  62. ${link('elsewhere', 'Elsewhere')}
  63. ${link('health', 'Data health')}
  64. </div>`
  65. }
  66. /**
  67. * Tab order. My day comes early because "what do I personally owe today" is
  68. * the board's first job on open; Onboarding sits right after Open work; the
  69. * Inbox is in the bar (a route with no tab is invisible); and there is one
  70. * Briefing tab, with the prose/numbers split inside the surface as a
  71. * segmented control rather than two near-identical tabs.
  72. *
  73. * Onboarding is the only conditional entry: it is dropped for a viewer with no
  74. * plan to read, which is why the array is filtered at render rather than
  75. * being a plain constant.
  76. */
  77. const TABS = [
  78. { id: 'board', label: 'Open work' },
  79. { id: 'onboarding', label: 'Onboarding' },
  80. { id: 'my-day', label: 'My day' },
  81. { id: 'queue', label: 'The queue' },
  82. { id: 'briefing', label: 'Briefing' },
  83. { id: 'elsewhere', label: 'Elsewhere' },
  84. { id: 'health', label: 'Data health' },
  85. ] as const
  86. /**
  87. * Client-side behaviour, in full. Deliberately small and dependency-free: this
  88. * file is opened straight off a disk or an email attachment, so anything that
  89. * needs a network round trip is not available to it.
  90. */
  91. const SCRIPT = `
  92. (function () {
  93. var d = document;
  94. var $$ = function (s, r) { return Array.prototype.slice.call((r || d).querySelectorAll(s)); };
  95. // Scripting is on, so hide everything but the first surface. Doing it here
  96. // rather than in the markup is what keeps the no-JS page readable.
  97. var surfaces = $$('.surface');
  98. var tabs = $$('.tab');
  99. function show(id) {
  100. surfaces.forEach(function (s) { s.hidden = s.id !== 'surface-' + id; });
  101. tabs.forEach(function (t) { t.setAttribute('aria-selected', String(t.dataset.tab === id)); });
  102. try { history.replaceState(null, '', '#' + id); } catch (e) {}
  103. }
  104. tabs.forEach(function (t) { t.addEventListener('click', function () { show(t.dataset.tab); }); });
  105. // Arrow keys walk the tablist, as the tab role promises a keyboard user.
  106. tabs.forEach(function (t, idx) {
  107. t.addEventListener('keydown', function (e) {
  108. var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
  109. if (!d) return;
  110. e.preventDefault();
  111. var n = tabs[(idx + d + tabs.length) % tabs.length];
  112. n.focus();
  113. show(n.dataset.tab);
  114. });
  115. });
  116. $$('[data-goto]').forEach(function (a) {
  117. a.addEventListener('click', function (e) { e.preventDefault(); show(a.dataset.goto); window.scrollTo(0, 0); });
  118. });
  119. // Drill-downs. Clicking the open tile closes it, so the tile is a toggle and
  120. // never a trap.
  121. var panels = $$('[data-drill-panel]');
  122. var triggers = $$('[data-drill]');
  123. function closeDrills() {
  124. panels.forEach(function (p) { p.hidden = true; });
  125. triggers.forEach(function (t) { t.setAttribute('aria-expanded', 'false'); });
  126. }
  127. triggers.forEach(function (t) {
  128. t.addEventListener('click', function () {
  129. var open = t.getAttribute('aria-expanded') === 'true';
  130. closeDrills();
  131. if (open) return;
  132. t.setAttribute('aria-expanded', 'true');
  133. panels.forEach(function (p) { if (p.dataset.drillPanel === t.dataset.drill) p.hidden = false; });
  134. });
  135. });
  136. $$('[data-drill-close]').forEach(function (b) { b.addEventListener('click', closeDrills); });
  137. // Topic pop-outs on My Day. A SEPARATE NAMESPACE from the drills above, not a
  138. // reuse: closeDrills() closes every [data-drill-panel] on the page, so sharing
  139. // it would make opening a meeting topic silently close a headline tile's
  140. // drill. Same toggle behaviour: clicking the open button closes it, so a
  141. // button is never a trap.
  142. var topicPanels = $$('[data-topic-panel]');
  143. var topicBtns = $$('[data-topic]');
  144. function closeTopics() {
  145. topicPanels.forEach(function (p) { p.hidden = true; });
  146. topicBtns.forEach(function (t) { t.setAttribute('aria-expanded', 'false'); });
  147. }
  148. topicBtns.forEach(function (t) {
  149. t.addEventListener('click', function () {
  150. var open = t.getAttribute('aria-expanded') === 'true';
  151. closeTopics();
  152. if (open) return;
  153. t.setAttribute('aria-expanded', 'true');
  154. topicPanels.forEach(function (p) { if (p.dataset.topicPanel === t.dataset.topic) p.hidden = false; });
  155. });
  156. });
  157. $$('[data-topic-close]').forEach(function (b) { b.addEventListener('click', closeTopics); });
  158. // Load basis: our-move only, or plus waiting-on-client.
  159. $$('[data-basis]').forEach(function (b) {
  160. b.addEventListener('click', function () {
  161. $$('[data-basis]').forEach(function (o) { o.setAttribute('aria-pressed', String(o === b)); });
  162. $$('[data-basis-panel]').forEach(function (p) { p.hidden = p.dataset.basisPanel !== b.dataset.basis; });
  163. });
  164. });
  165. // No person picker: access control cannot live in the browser, so the page is
  166. // rendered per viewer with a single My-day card and there is nothing to switch.
  167. // Briefing: prose or numbers. Two pre-rendered panels, same pattern as the
  168. // load basis. Nothing is fetched, only which panel is hidden changes.
  169. $$('[data-brief-view]').forEach(function (b) {
  170. b.addEventListener('click', function () {
  171. $$('[data-brief-view]').forEach(function (o) { o.setAttribute('aria-pressed', String(o === b)); });
  172. $$('[data-brief-panel]').forEach(function (p) { p.hidden = p.dataset.briefPanel !== b.dataset.briefView; });
  173. });
  174. });
  175. // Theme. The cookie is what makes the SERVER render the right theme next
  176. // load (the Worker reads it and stamps data-theme before the page leaves);
  177. // this handler only makes the click take effect without a round trip.
  178. function applyTheme(v) {
  179. if (v === 'dark' || (v === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)) {
  180. d.documentElement.setAttribute('data-theme', 'dark');
  181. } else {
  182. d.documentElement.removeAttribute('data-theme');
  183. }
  184. $$('[data-set-theme]').forEach(function (b) {
  185. b.setAttribute('aria-pressed', String(b.dataset.setTheme === v));
  186. });
  187. }
  188. $$('[data-set-theme]').forEach(function (b) {
  189. b.addEventListener('click', function (e) {
  190. // With scripting, the switch is instant and local; without it, the same
  191. // button is a real form POST to /theme and the server sets the cookie.
  192. e.preventDefault();
  193. var v = b.dataset.setTheme;
  194. try { d.cookie = 'theme=' + v + ';path=/;max-age=31536000;samesite=lax'; } catch (err) {}
  195. applyTheme(v);
  196. });
  197. });
  198. // Cookie access can THROW, not just return '' (a data: URL preview, or a
  199. // browser with cookies fully blocked), and an unhandled throw here kills
  200. // everything below it (tab restore, the notes UI). Theme falls back to
  201. // system; nothing else on this page depends on cookies.
  202. var saved = 'system';
  203. try { saved = (d.cookie.match(/(?:^|; )theme=([a-z]+)/) || [])[1] || 'system'; } catch (e) {}
  204. applyTheme(saved);
  205. var start = (location.hash || '').replace('#', '');
  206. show(tabs.some(function (t) { return t.dataset.tab === start; }) ? start : tabs[0].dataset.tab);
  207. // User notes on orders and tasks. The notes themselves are not in this
  208. // pre-rendered page: the Worker injects them at
  209. // serve time as a JSON <script id="board-notes"> so a note saved a minute ago
  210. // shows on the next load without a re-render. No tag (a local file render, or
  211. // an old Worker) means no note UI at all. A button that could only lose what
  212. // was typed into it is worse than none. Everything user-written renders via
  213. // textContent, never innerHTML: notes are typed by people, and one person's
  214. // note must not become script in a colleague's browser.
  215. // Deferred: the Worker injects the notes JSON at the END of body, after this
  216. // script tag; read it before the parser gets there and it does not exist
  217. // yet. (And this comment must never spell out the closing body tag literally:
  218. // the injector anchors on that string, and the first draft of this comment
  219. // contained it, which spliced the JSON into the middle of this script.)
  220. function bootNotes() {
  221. var tag = d.getElementById('board-notes');
  222. if (tag) {
  223. var notes = {};
  224. try { notes = JSON.parse(tag.textContent || '{}') || {}; } catch (e) {}
  225. $$('[data-note-key]').forEach(function (row) {
  226. var key = row.getAttribute('data-note-key');
  227. var n = notes[key];
  228. var isTr = row.tagName === 'TR';
  229. var btn = d.createElement('button');
  230. btn.type = 'button';
  231. btn.className = 'note-btn' + (n ? ' has-note' : '');
  232. btn.textContent = n ? 'Note' : '+ Note';
  233. btn.title = n ? 'A note from ' + n.by + '. Click to read' : 'Add a note everyone can see';
  234. var panel = null;
  235. btn.addEventListener('click', function () {
  236. if (panel) { panel.remove(); panel = null; return; }
  237. var box = d.createElement('div');
  238. box.className = 'note-panel';
  239. if (n) {
  240. var txt = d.createElement('p'); txt.className = 'note-text'; txt.textContent = n.text;
  241. var by = d.createElement('p'); by.className = 'note-by';
  242. // Central time, not a slice of the ISO string. A note added after
  243. // 7 PM CT is stored as tomorrow in UTC, and slicing showed tomorrow's
  244. // date to the person who had just typed it. This runs in the browser
  245. // so it cannot use localDay(); the timeZone option is the same fix.
  246. var at = n.at ? new Date(n.at) : null;
  247. by.textContent = 'From ' + n.by + (at && !isNaN(at.getTime())
  248. ? ' · ' + at.toLocaleDateString('en-CA', { timeZone: 'America/Chicago' })
  249. : '');
  250. box.appendChild(txt); box.appendChild(by);
  251. }
  252. var form = d.createElement('form');
  253. form.method = 'post'; form.action = '/note';
  254. var hk = d.createElement('input'); hk.type = 'hidden'; hk.name = 'key'; hk.value = key;
  255. var hb = d.createElement('input'); hb.type = 'hidden'; hb.name = 'back';
  256. hb.value = (location.hash || '').replace('#', '');
  257. var ta = d.createElement('textarea'); ta.name = 'text'; ta.rows = 3; ta.maxLength = 2000;
  258. ta.placeholder = 'Why this is where it is (visible to everyone on the board)';
  259. ta.value = n ? n.text : '';
  260. var save = d.createElement('button'); save.type = 'submit'; save.className = 'note-btn';
  261. save.textContent = n ? 'Update note' : 'Save note';
  262. var hint = d.createElement('span'); hint.className = 'note-hint';
  263. hint.textContent = n ? 'Saving empty removes it. Shared with everyone.' : 'Shared with everyone who opens the board.';
  264. form.appendChild(hk); form.appendChild(hb); form.appendChild(ta);
  265. var bar = d.createElement('div'); bar.className = 'note-bar';
  266. bar.appendChild(save); bar.appendChild(hint);
  267. form.appendChild(bar);
  268. box.appendChild(form);
  269. if (isTr) {
  270. var tr = d.createElement('tr'); tr.className = 'note-row';
  271. var td = d.createElement('td'); td.colSpan = row.children.length;
  272. td.appendChild(box); tr.appendChild(td);
  273. row.parentNode.insertBefore(tr, row.nextSibling);
  274. panel = tr;
  275. } else {
  276. row.parentNode.insertBefore(box, row.nextSibling);
  277. panel = box;
  278. }
  279. ta.focus();
  280. });
  281. if (isTr) { row.lastElementChild.appendChild(btn); }
  282. else { row.appendChild(btn); }
  283. });
  284. }
  285. }
  286. if (d.readyState === 'loading') d.addEventListener('DOMContentLoaded', bootNotes);
  287. else bootNotes();
  288. })();
  289. `
  290. /** SourceTone + status -> the texture class. Hue left the health system. */
  291. function healthClass(tone: string, status: string): string {
  292. if (tone === 'failed') return 'failed'
  293. if (tone === 'degraded' || tone === 'absent') return 'stale'
  294. if (/read/i.test(status)) return 'read'
  295. return 'live'
  296. }
  297. function masthead(vm: ReturnType<typeof buildViewModel>, synced: string): string {
  298. const clean = vm.sources.filter((s) => s.tone === 'live').length
  299. return `<header class="masthead">
  300. <div class="top" style="display:flex;align-items:flex-start;justify-content:space-between;gap:24px;width:100%">
  301. <div class="mast-left">
  302. <div class="eyebrow">Meridian Display · Operations</div>
  303. <h1>Open Work</h1>
  304. <p class="lede">
  305. Everything Meridian owes a move on, in one place. Orders from Plinth, leads and tasks from
  306. Bitrix, commitments from Fathom, to-dos from Ninety.
  307. </p>
  308. </div>
  309. <div class="mast-right" style="flex:none">
  310. <div class="sources">
  311. ${map(
  312. vm.sources,
  313. (s) =>
  314. `<span class="pill ${healthClass(s.tone, s.status)}" title="${esc(s.note.slice(0, 140))}">
  315. <i></i>${esc(s.name)} · ${esc(s.status)}
  316. </span>`,
  317. )}
  318. </div>
  319. <div class="synced">
  320. Synced ${esc(synced)} CT · <b>polled, never live</b> ·
  321. ${clean} of ${vm.sources.length} sources reading clean
  322. <!-- SYNC_STATE is replaced at serve time by the Worker, the same way
  323. the theme is stamped into a stored page. It has to be: this
  324. document was rendered by a cron and cannot know whether a sync
  325. started since. Left alone it renders as nothing at all, which is
  326. the correct appearance when no sync is running. -->
  327. <span id="sync-state"></span>
  328. <form class="sync-ctl" method="post" action="/sync" style="display:inline-flex">
  329. <button type="submit" title="Re-read all five sources and the seven mailboxes now. Takes about five minutes end to end: a few for the request to reach the runner, then a minute or two to run, because the Gmail reads are paced to stay inside Gmail's rate limit. Does not write the day's history row, and does not run the written briefing, which is a weekday-morning job on a laptop.">Sync now</button>
  330. </form>
  331. <form class="theme-ctl" method="post" action="/theme" data-theme-ctl role="group" aria-label="Theme" style="display:inline-flex">
  332. <button type="submit" name="theme" value="light" data-set-theme="light">Light</button>
  333. <button type="submit" name="theme" value="dark" data-set-theme="dark">Dark</button>
  334. <button type="submit" name="theme" value="system" data-set-theme="system">System</button>
  335. </form>
  336. </div>
  337. </div>
  338. </div>
  339. </header>`
  340. }
  341. export function render(
  342. snap: Snapshot,
  343. h: Headline,
  344. history: HistoryRow[] = [],
  345. today = '',
  346. briefings: Briefings | null = null,
  347. /**
  348. * Who this copy is for. Every restricted section is omitted from the markup for
  349. * viewers who may not read it: not hidden with CSS, omitted. `hidden` is
  350. * styling, not privacy.
  351. *
  352. * Defaults to the company view, so a caller that forgets the argument leaks
  353. * nothing. Getting less than you should is a bug report; getting more is an
  354. * incident.
  355. */
  356. viewer: Viewer = COMPANY_VIEWER,
  357. ): string {
  358. // Only viewers granted a mailbox get the tab. canReadInbox() reads
  359. // INBOX_READERS, so a viewer with no line there gets no tab.
  360. const inboxHref = canReadInbox(viewer) ? '/inbox' : null
  361. // Same shape as the inbox: a tab this viewer may not read is absent from the
  362. // markup, not hidden in it. `hasOnboarding` is false for everyone once the
  363. // last plan is switched off, which is what makes the toggle a toggle.
  364. const showOnboarding = hasOnboarding(viewer)
  365. const tabs = TABS.filter((t) => t.id !== 'onboarding' || showOnboarding)
  366. const day = today || localDay(new Date(snap.syncedAt))
  367. const vm = buildViewModel(snap, h, history, day, viewer)
  368. const synced = new Intl.DateTimeFormat('en-US', {
  369. timeZone: MERIDIAN_TZ,
  370. dateStyle: 'medium',
  371. timeStyle: 'short',
  372. }).format(new Date(snap.syncedAt))
  373. const surface = (id: string, body: string): string =>
  374. `<div class="surface" id="surface-${id}" role="tabpanel" aria-labelledby="tab-${id}">${body}</div>`
  375. return `<!doctype html>
  376. <html lang="en">
  377. <head>
  378. <script>(function(){try{var m=document.cookie.match(/(?:^|; )theme=([a-z]+)/);var v=m?m[1]:'system';
  379. if(v==='dark'||(v==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.setAttribute('data-theme','dark');}catch(e){}})();</script>
  380. <meta charset="utf-8">
  381. <meta name="viewport" content="width=device-width,initial-scale=1">
  382. <title>Meridian Display · Open Work</title>
  383. <style>${THEME_CSS}${QUEUE_CSS}</style>
  384. </head>
  385. <body>
  386. <a class="skip-link" href="#main">Skip to the board</a>
  387. <div class="rule"></div>
  388. <div class="page">
  389. ${masthead(vm, synced)}
  390. ${
  391. snap.mode === 'fixtures'
  392. ? `<div class="caveat down" style="margin-top:20px" role="status">
  393. <b>Sample data, not today's work.</b>
  394. <span>Every number on this page comes from a frozen export captured
  395. ${esc(snap.fixtureCapturedAt?.slice(0, 10) ?? 'earlier')}. Orders listed here as open have
  396. since shipped. Nothing was queried. Run <code>pnpm dashboard</code> without
  397. <code>--fixtures</code> for the real board.</span>
  398. </div>`
  399. : ''
  400. }
  401. ${
  402. vm.failures.length
  403. ? `<div class="caveat down" style="margin-top:20px">
  404. <b>A source failed:</b>
  405. <span>${esc(vm.failures.join(' · '))}. The numbers it feeds are missing from this page, not zero.</span>
  406. </div>`
  407. : ''
  408. }
  409. ${
  410. vm.caveatRows.length
  411. ? `<div class="caveat-band" style="margin-top:${vm.failures.length ? '10px' : '20px'}">
  412. <div class="caveat-head">
  413. <span class="caveat-chip">Check this</span>
  414. <span class="t-small" style="color:var(--text-2)">
  415. ${vm.caveatRows.length === 1 ? 'One thing' : `${['','','Two','Three','Four','Five','Six','Seven'][vm.caveatRows.length] ?? vm.caveatRows.length} things`}
  416. about today's numbers worth knowing before you quote one. None of them is an error;
  417. they are the shape of the sources.
  418. </span>
  419. </div>
  420. ${map(
  421. vm.caveatRows,
  422. (c) => `<div class="caveat-row"><div class="src">${esc(c.src)}</div><div class="t-small">${esc(c.text)}</div></div>`,
  423. )}
  424. </div>`
  425. : ''
  426. }
  427. <div class="tabs" role="tablist">
  428. ${map(
  429. tabs,
  430. (t) =>
  431. `<button type="button" class="tab" id="tab-${t.id}" data-tab="${t.id}" role="tab"
  432. aria-selected="false" aria-controls="surface-${t.id}">${esc(t.label)}</button>` +
  433. // The inbox is a link, not a tab panel, and the difference is deliberate.
  434. // Every other surface is this same 6:00 AM snapshot; the inbox renders at
  435. // request time so its filter buttons take effect on the click rather than
  436. // tomorrow. Shown only to viewers who may read it, and omitted from the
  437. // markup rather than hidden. A tab that leads to a denial is worse than
  438. // no tab.
  439. //
  440. // Anchored to the tab before it, not to an index: the list length
  441. // depends on who is looking (Onboarding is conditional), so a fixed
  442. // index would put the Inbox in a different place for different viewers.
  443. (t.id === 'queue' && inboxHref ? `<a class="tab" href="${esc(inboxHref)}">Inbox</a>` : ''),
  444. )}
  445. </div>
  446. <main id="main">
  447. ${join([
  448. // The company briefing reads where the work is: on Open work, below the
  449. // headline numbers (the numbers lead). The Briefing tab remains
  450. // as the archive and audit trail.
  451. surface('board', board(vm, inlineBriefing(briefings, snap, viewer) + legendStrip())),
  452. // Omitted entirely for a viewer with no plan. The surface is not rendered
  453. // and then hidden, it is not rendered. Hidden markup is not privacy.
  454. showOnboarding ? surface('onboarding', onboardingSurface(viewer)) : '',
  455. surface('my-day', myDaySurface(vm, briefings, viewer)),
  456. surface('queue', `<section class="section">${queueSurface(vm)}</section>`),
  457. surface(
  458. 'briefing',
  459. `<section class="section">
  460. <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px">
  461. <div class="section-head" style="margin:0"><h2>Briefing</h2>
  462. <span class="hint">the same facts twice: read them, or scan them</span></div>
  463. <div class="seg-control" role="group" aria-label="Briefing view">
  464. <button type="button" data-brief-view="prose" aria-pressed="true">In prose</button>
  465. <button type="button" data-brief-view="data" aria-pressed="false">In numbers</button>
  466. </div>
  467. </div>
  468. ${writtenBriefing(briefings, snap, viewer)}
  469. <div data-brief-panel="prose">${briefingProse(vm)}</div>
  470. <div data-brief-panel="data" hidden>${briefingData(vm)}</div>
  471. </section>`,
  472. ),
  473. surface('elsewhere', `<section class="section">${elsewhere(vm)}</section>`),
  474. surface('health', `<section class="section">${dataHealth(vm)}</section>`),
  475. ])}
  476. </main>
  477. <div class="section card" style="border:0">
  478. <div class="rule"></div>
  479. <div class="howto">
  480. <b>How to read this.</b> Only &ldquo;our move&rdquo; is workload. Counting waiting-on-client and
  481. at-vendor as backlog is how a dashboard makes the team look behind when it is not.
  482. Leads are grouped by stage, never by person: Bitrix&rsquo;s assignee field is a routing default and
  483. every open lead sampled belonged to one person. Order workload <i>is</i> per person, from the Plinth
  484. Production Owner and Account Owner fields.
  485. Work is identified at the order level, which is how Meridian assigns it.
  486. Stage classifications live in <code>config/bitrix-stages.ts</code> and
  487. <code>packages/plinth-client/src/status-taxonomy.ts</code> still want a review pass from Theo.
  488. </div>
  489. </div>
  490. </div>
  491. <script>${SCRIPT}</script>
  492. </body>
  493. </html>`
  494. }