render.ts
Language TypeScript23.7 KB519 lines
/*** Assemble the surfaces into one self-contained `dashboard.html`.** One file with tabs rather than six pages. The mockup lays the surfaces out side by side because that is how a design* canvas works. In use they are one artifact: the same 6:00 AM snapshot, read* five different ways. Six files would mean six chances for one of them to be* yesterday's, and "the briefing says 146 but the board says 151" destroys trust* in both faster than any missing feature.** So: one snapshot, one file, tabs. It opens from disk with no server, survives* being emailed, and prints.** The page is usable without JavaScript. Tabs, drill-downs and the person picker* are progressive enhancement: with scripting off, every surface is simply* visible in sequence. A briefing that goes blank in a locked-down browser at* 9:10 is worse than one that scrolls.*/import type { Headline, Snapshot } from './collect.js'import { MERIDIAN_TZ, localDay } from '@meridian/ninety-client'import { esc, join, map } from './html.js'import { THEME_CSS, QUEUE_CSS } from './theme.js'import { canReadInbox } from '../../../config/inbox.js'import { hasOnboarding } from '../../../config/onboarding.js'import { buildViewModel } from './view-model.js'import type { HistoryRow } from './history.js'import type { Briefings } from './briefings.js'import { COMPANY_VIEWER, type Viewer } from './viewer.js'import {board,legendStrip,briefingData,briefingProse,dataHealth,elsewhere,inlineBriefing,myDaySurface,onboardingSurface,queueSurface,writtenBriefing,} from './surfaces.js'/*** The tab bar for a route page (the inbox): identical bar, but every tab is a* link into the board's hash-deep-links, and the current route is marked with* aria-current, so the inbox reads as part of the board. A "back" link is* navigation debt, not navigation.*/export function routeTabBar(current: 'inbox' | 'none', showOnboarding = false): string {const link = (id: string, label: string): string =>`<a class="tab" href="/#${id}">${esc(label)}</a>`return `<div class="tabs" role="navigation" aria-label="Board">${link('board', 'Open work')}${// Same rule as the board's own bar: omitted, not disabled. A link into a// surface the board will not render for this viewer is a link to the// first tab, which reads as a broken one.showOnboarding ? link('onboarding', 'Onboarding') : ''}${link('my-day', 'My day')}${link('queue', 'The queue')}<a class="tab" href="/inbox" aria-current="${current === 'inbox' ? 'page' : 'false'}">Inbox</a>${link('briefing', 'Briefing')}${link('elsewhere', 'Elsewhere')}${link('health', 'Data health')}</div>`}/*** Tab order. My day comes early because "what do I personally owe today" is* the board's first job on open; Onboarding sits right after Open work; the* Inbox is in the bar (a route with no tab is invisible); and there is one* Briefing tab, with the prose/numbers split inside the surface as a* segmented control rather than two near-identical tabs.** Onboarding is the only conditional entry: it is dropped for a viewer with no* plan to read, which is why the array is filtered at render rather than* being a plain constant.*/const TABS = [{ id: 'board', label: 'Open work' },{ id: 'onboarding', label: 'Onboarding' },{ id: 'my-day', label: 'My day' },{ id: 'queue', label: 'The queue' },{ id: 'briefing', label: 'Briefing' },{ id: 'elsewhere', label: 'Elsewhere' },{ id: 'health', label: 'Data health' },] as const/*** Client-side behaviour, in full. Deliberately small and dependency-free: this* file is opened straight off a disk or an email attachment, so anything that* needs a network round trip is not available to it.*/const SCRIPT = `(function () {var d = document;var $$ = function (s, r) { return Array.prototype.slice.call((r || d).querySelectorAll(s)); };// Scripting is on, so hide everything but the first surface. Doing it here// rather than in the markup is what keeps the no-JS page readable.var surfaces = $$('.surface');var tabs = $$('.tab');function show(id) {surfaces.forEach(function (s) { s.hidden = s.id !== 'surface-' + id; });tabs.forEach(function (t) { t.setAttribute('aria-selected', String(t.dataset.tab === id)); });try { history.replaceState(null, '', '#' + id); } catch (e) {}}tabs.forEach(function (t) { t.addEventListener('click', function () { show(t.dataset.tab); }); });// Arrow keys walk the tablist, as the tab role promises a keyboard user.tabs.forEach(function (t, idx) {t.addEventListener('keydown', function (e) {var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;if (!d) return;e.preventDefault();var n = tabs[(idx + d + tabs.length) % tabs.length];n.focus();show(n.dataset.tab);});});$$('[data-goto]').forEach(function (a) {a.addEventListener('click', function (e) { e.preventDefault(); show(a.dataset.goto); window.scrollTo(0, 0); });});// Drill-downs. Clicking the open tile closes it, so the tile is a toggle and// never a trap.var panels = $$('[data-drill-panel]');var triggers = $$('[data-drill]');function closeDrills() {panels.forEach(function (p) { p.hidden = true; });triggers.forEach(function (t) { t.setAttribute('aria-expanded', 'false'); });}triggers.forEach(function (t) {t.addEventListener('click', function () {var open = t.getAttribute('aria-expanded') === 'true';closeDrills();if (open) return;t.setAttribute('aria-expanded', 'true');panels.forEach(function (p) { if (p.dataset.drillPanel === t.dataset.drill) p.hidden = false; });});});$$('[data-drill-close]').forEach(function (b) { b.addEventListener('click', closeDrills); });// Topic pop-outs on My Day. A SEPARATE NAMESPACE from the drills above, not a// reuse: closeDrills() closes every [data-drill-panel] on the page, so sharing// it would make opening a meeting topic silently close a headline tile's// drill. Same toggle behaviour: clicking the open button closes it, so a// button is never a trap.var topicPanels = $$('[data-topic-panel]');var topicBtns = $$('[data-topic]');function closeTopics() {topicPanels.forEach(function (p) { p.hidden = true; });topicBtns.forEach(function (t) { t.setAttribute('aria-expanded', 'false'); });}topicBtns.forEach(function (t) {t.addEventListener('click', function () {var open = t.getAttribute('aria-expanded') === 'true';closeTopics();if (open) return;t.setAttribute('aria-expanded', 'true');topicPanels.forEach(function (p) { if (p.dataset.topicPanel === t.dataset.topic) p.hidden = false; });});});$$('[data-topic-close]').forEach(function (b) { b.addEventListener('click', closeTopics); });// Load basis: our-move only, or plus waiting-on-client.$$('[data-basis]').forEach(function (b) {b.addEventListener('click', function () {$$('[data-basis]').forEach(function (o) { o.setAttribute('aria-pressed', String(o === b)); });$$('[data-basis-panel]').forEach(function (p) { p.hidden = p.dataset.basisPanel !== b.dataset.basis; });});});// No person picker: access control cannot live in the browser, so the page is// rendered per viewer with a single My-day card and there is nothing to switch.// Briefing: prose or numbers. Two pre-rendered panels, same pattern as the// load basis. Nothing is fetched, only which panel is hidden changes.$$('[data-brief-view]').forEach(function (b) {b.addEventListener('click', function () {$$('[data-brief-view]').forEach(function (o) { o.setAttribute('aria-pressed', String(o === b)); });$$('[data-brief-panel]').forEach(function (p) { p.hidden = p.dataset.briefPanel !== b.dataset.briefView; });});});// Theme. The cookie is what makes the SERVER render the right theme next// load (the Worker reads it and stamps data-theme before the page leaves);// this handler only makes the click take effect without a round trip.function applyTheme(v) {if (v === 'dark' || (v === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)) {d.documentElement.setAttribute('data-theme', 'dark');} else {d.documentElement.removeAttribute('data-theme');}$$('[data-set-theme]').forEach(function (b) {b.setAttribute('aria-pressed', String(b.dataset.setTheme === v));});}$$('[data-set-theme]').forEach(function (b) {b.addEventListener('click', function (e) {// With scripting, the switch is instant and local; without it, the same// button is a real form POST to /theme and the server sets the cookie.e.preventDefault();var v = b.dataset.setTheme;try { d.cookie = 'theme=' + v + ';path=/;max-age=31536000;samesite=lax'; } catch (err) {}applyTheme(v);});});// Cookie access can THROW, not just return '' (a data: URL preview, or a// browser with cookies fully blocked), and an unhandled throw here kills// everything below it (tab restore, the notes UI). Theme falls back to// system; nothing else on this page depends on cookies.var saved = 'system';try { saved = (d.cookie.match(/(?:^|; )theme=([a-z]+)/) || [])[1] || 'system'; } catch (e) {}applyTheme(saved);var start = (location.hash || '').replace('#', '');show(tabs.some(function (t) { return t.dataset.tab === start; }) ? start : tabs[0].dataset.tab);// User notes on orders and tasks. The notes themselves are not in this// pre-rendered page: the Worker injects them at// serve time as a JSON <script id="board-notes"> so a note saved a minute ago// shows on the next load without a re-render. No tag (a local file render, or// an old Worker) means no note UI at all. A button that could only lose what// was typed into it is worse than none. Everything user-written renders via// textContent, never innerHTML: notes are typed by people, and one person's// note must not become script in a colleague's browser.// Deferred: the Worker injects the notes JSON at the END of body, after this// script tag; read it before the parser gets there and it does not exist// yet. (And this comment must never spell out the closing body tag literally:// the injector anchors on that string, and the first draft of this comment// contained it, which spliced the JSON into the middle of this script.)function bootNotes() {var tag = d.getElementById('board-notes');if (tag) {var notes = {};try { notes = JSON.parse(tag.textContent || '{}') || {}; } catch (e) {}$$('[data-note-key]').forEach(function (row) {var key = row.getAttribute('data-note-key');var n = notes[key];var isTr = row.tagName === 'TR';var btn = d.createElement('button');btn.type = 'button';btn.className = 'note-btn' + (n ? ' has-note' : '');btn.textContent = n ? 'Note' : '+ Note';btn.title = n ? 'A note from ' + n.by + '. Click to read' : 'Add a note everyone can see';var panel = null;btn.addEventListener('click', function () {if (panel) { panel.remove(); panel = null; return; }var box = d.createElement('div');box.className = 'note-panel';if (n) {var txt = d.createElement('p'); txt.className = 'note-text'; txt.textContent = n.text;var by = d.createElement('p'); by.className = 'note-by';// Central time, not a slice of the ISO string. A note added after// 7 PM CT is stored as tomorrow in UTC, and slicing showed tomorrow's// date to the person who had just typed it. This runs in the browser// so it cannot use localDay(); the timeZone option is the same fix.var at = n.at ? new Date(n.at) : null;by.textContent = 'From ' + n.by + (at && !isNaN(at.getTime())? ' · ' + at.toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }): '');box.appendChild(txt); box.appendChild(by);}var form = d.createElement('form');form.method = 'post'; form.action = '/note';var hk = d.createElement('input'); hk.type = 'hidden'; hk.name = 'key'; hk.value = key;var hb = d.createElement('input'); hb.type = 'hidden'; hb.name = 'back';hb.value = (location.hash || '').replace('#', '');var ta = d.createElement('textarea'); ta.name = 'text'; ta.rows = 3; ta.maxLength = 2000;ta.placeholder = 'Why this is where it is (visible to everyone on the board)';ta.value = n ? n.text : '';var save = d.createElement('button'); save.type = 'submit'; save.className = 'note-btn';save.textContent = n ? 'Update note' : 'Save note';var hint = d.createElement('span'); hint.className = 'note-hint';hint.textContent = n ? 'Saving empty removes it. Shared with everyone.' : 'Shared with everyone who opens the board.';form.appendChild(hk); form.appendChild(hb); form.appendChild(ta);var bar = d.createElement('div'); bar.className = 'note-bar';bar.appendChild(save); bar.appendChild(hint);form.appendChild(bar);box.appendChild(form);if (isTr) {var tr = d.createElement('tr'); tr.className = 'note-row';var td = d.createElement('td'); td.colSpan = row.children.length;td.appendChild(box); tr.appendChild(td);row.parentNode.insertBefore(tr, row.nextSibling);panel = tr;} else {row.parentNode.insertBefore(box, row.nextSibling);panel = box;}ta.focus();});if (isTr) { row.lastElementChild.appendChild(btn); }else { row.appendChild(btn); }});}}if (d.readyState === 'loading') d.addEventListener('DOMContentLoaded', bootNotes);else bootNotes();})();`/** SourceTone + status -> the texture class. Hue left the health system. */function healthClass(tone: string, status: string): string {if (tone === 'failed') return 'failed'if (tone === 'degraded' || tone === 'absent') return 'stale'if (/read/i.test(status)) return 'read'return 'live'}function masthead(vm: ReturnType<typeof buildViewModel>, synced: string): string {const clean = vm.sources.filter((s) => s.tone === 'live').lengthreturn `<header class="masthead"><div class="top" style="display:flex;align-items:flex-start;justify-content:space-between;gap:24px;width:100%"><div class="mast-left"><div class="eyebrow">Meridian Display · Operations</div><h1>Open Work</h1><p class="lede">Everything Meridian owes a move on, in one place. Orders from Plinth, leads and tasks fromBitrix, commitments from Fathom, to-dos from Ninety.</p></div><div class="mast-right" style="flex:none"><div class="sources">${map(vm.sources,(s) =>`<span class="pill ${healthClass(s.tone, s.status)}" title="${esc(s.note.slice(0, 140))}"><i></i>${esc(s.name)} · ${esc(s.status)}</span>`,)}</div><div class="synced">Synced ${esc(synced)} CT · <b>polled, never live</b> ·${clean} of ${vm.sources.length} sources reading clean<!-- SYNC_STATE is replaced at serve time by the Worker, the same waythe theme is stamped into a stored page. It has to be: thisdocument was rendered by a cron and cannot know whether a syncstarted since. Left alone it renders as nothing at all, which isthe correct appearance when no sync is running. --><span id="sync-state"></span><form class="sync-ctl" method="post" action="/sync" style="display:inline-flex"><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></form><form class="theme-ctl" method="post" action="/theme" data-theme-ctl role="group" aria-label="Theme" style="display:inline-flex"><button type="submit" name="theme" value="light" data-set-theme="light">Light</button><button type="submit" name="theme" value="dark" data-set-theme="dark">Dark</button><button type="submit" name="theme" value="system" data-set-theme="system">System</button></form></div></div></div></header>`}export function render(snap: Snapshot,h: Headline,history: HistoryRow[] = [],today = '',briefings: Briefings | null = null,/*** Who this copy is for. Every restricted section is omitted from the markup for* viewers who may not read it: not hidden with CSS, omitted. `hidden` is* styling, not privacy.** Defaults to the company view, so a caller that forgets the argument leaks* nothing. Getting less than you should is a bug report; getting more is an* incident.*/viewer: Viewer = COMPANY_VIEWER,): string {// Only viewers granted a mailbox get the tab. canReadInbox() reads// INBOX_READERS, so a viewer with no line there gets no tab.const inboxHref = canReadInbox(viewer) ? '/inbox' : null// Same shape as the inbox: a tab this viewer may not read is absent from the// markup, not hidden in it. `hasOnboarding` is false for everyone once the// last plan is switched off, which is what makes the toggle a toggle.const showOnboarding = hasOnboarding(viewer)const tabs = TABS.filter((t) => t.id !== 'onboarding' || showOnboarding)const day = today || localDay(new Date(snap.syncedAt))const vm = buildViewModel(snap, h, history, day, viewer)const synced = new Intl.DateTimeFormat('en-US', {timeZone: MERIDIAN_TZ,dateStyle: 'medium',timeStyle: 'short',}).format(new Date(snap.syncedAt))const surface = (id: string, body: string): string =>`<div class="surface" id="surface-${id}" role="tabpanel" aria-labelledby="tab-${id}">${body}</div>`return `<!doctype html><html lang="en"><head><script>(function(){try{var m=document.cookie.match(/(?:^|; )theme=([a-z]+)/);var v=m?m[1]:'system';if(v==='dark'||(v==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.setAttribute('data-theme','dark');}catch(e){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Meridian Display · Open Work</title><style>${THEME_CSS}${QUEUE_CSS}</style></head><body><a class="skip-link" href="#main">Skip to the board</a><div class="rule"></div><div class="page">${masthead(vm, synced)}${snap.mode === 'fixtures'? `<div class="caveat down" style="margin-top:20px" role="status"><b>Sample data, not today's work.</b><span>Every number on this page comes from a frozen export captured${esc(snap.fixtureCapturedAt?.slice(0, 10) ?? 'earlier')}. Orders listed here as open havesince shipped. Nothing was queried. Run <code>pnpm dashboard</code> without<code>--fixtures</code> for the real board.</span></div>`: ''}${vm.failures.length? `<div class="caveat down" style="margin-top:20px"><b>A source failed:</b><span>${esc(vm.failures.join(' · '))}. The numbers it feeds are missing from this page, not zero.</span></div>`: ''}${vm.caveatRows.length? `<div class="caveat-band" style="margin-top:${vm.failures.length ? '10px' : '20px'}"><div class="caveat-head"><span class="caveat-chip">Check this</span><span class="t-small" style="color:var(--text-2)">${vm.caveatRows.length === 1 ? 'One thing' : `${['','','Two','Three','Four','Five','Six','Seven'][vm.caveatRows.length] ?? vm.caveatRows.length} things`}about today's numbers worth knowing before you quote one. None of them is an error;they are the shape of the sources.</span></div>${map(vm.caveatRows,(c) => `<div class="caveat-row"><div class="src">${esc(c.src)}</div><div class="t-small">${esc(c.text)}</div></div>`,)}</div>`: ''}<div class="tabs" role="tablist">${map(tabs,(t) =>`<button type="button" class="tab" id="tab-${t.id}" data-tab="${t.id}" role="tab"aria-selected="false" aria-controls="surface-${t.id}">${esc(t.label)}</button>` +// The inbox is a link, not a tab panel, and the difference is deliberate.// Every other surface is this same 6:00 AM snapshot; the inbox renders at// request time so its filter buttons take effect on the click rather than// tomorrow. Shown only to viewers who may read it, and omitted from the// markup rather than hidden. A tab that leads to a denial is worse than// no tab.//// Anchored to the tab before it, not to an index: the list length// depends on who is looking (Onboarding is conditional), so a fixed// index would put the Inbox in a different place for different viewers.(t.id === 'queue' && inboxHref ? `<a class="tab" href="${esc(inboxHref)}">Inbox</a>` : ''),)}</div><main id="main">${join([// The company briefing reads where the work is: on Open work, below the// headline numbers (the numbers lead). The Briefing tab remains// as the archive and audit trail.surface('board', board(vm, inlineBriefing(briefings, snap, viewer) + legendStrip())),// Omitted entirely for a viewer with no plan. The surface is not rendered// and then hidden, it is not rendered. Hidden markup is not privacy.showOnboarding ? surface('onboarding', onboardingSurface(viewer)) : '',surface('my-day', myDaySurface(vm, briefings, viewer)),surface('queue', `<section class="section">${queueSurface(vm)}</section>`),surface('briefing',`<section class="section"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px"><div class="section-head" style="margin:0"><h2>Briefing</h2><span class="hint">the same facts twice: read them, or scan them</span></div><div class="seg-control" role="group" aria-label="Briefing view"><button type="button" data-brief-view="prose" aria-pressed="true">In prose</button><button type="button" data-brief-view="data" aria-pressed="false">In numbers</button></div></div>${writtenBriefing(briefings, snap, viewer)}<div data-brief-panel="prose">${briefingProse(vm)}</div><div data-brief-panel="data" hidden>${briefingData(vm)}</div></section>`,),surface('elsewhere', `<section class="section">${elsewhere(vm)}</section>`),surface('health', `<section class="section">${dataHealth(vm)}</section>`),])}</main><div class="section card" style="border:0"><div class="rule"></div><div class="howto"><b>How to read this.</b> Only “our move” is workload. Counting waiting-on-client andat-vendor as backlog is how a dashboard makes the team look behind when it is not.Leads are grouped by stage, never by person: Bitrix’s assignee field is a routing default andevery open lead sampled belonged to one person. Order workload <i>is</i> per person, from the PlinthProduction Owner and Account Owner fields.Work is identified at the order level, which is how Meridian assigns it.Stage classifications live in <code>config/bitrix-stages.ts</code> and<code>packages/plinth-client/src/status-taxonomy.ts</code> still want a review pass from Theo.</div></div></div><script>${SCRIPT}</script></body></html>`}