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

surfaces.ts

Language TypeScript71.4 KB1572 lines

apps/dashboard/src/surfaces.ts

  1. /**
  2. * The six surfaces from the original dashboard mockups, built from real data.
  3. *
  4. * board 1a Bird's-eye. Headline numbers that drill down.
  5. * briefingProse 1b The morning briefing as prose. Reads like a person wrote it.
  6. * briefingData 1c The same facts, scannable in fifteen seconds.
  7. * myDay 1d One person: what is mine, what I promised.
  8. * elsewhere 1e What moved outside your lane.
  9. * dataHealth 1f What the board can and cannot see.
  10. *
  11. * 1g, the phone layout, is not a seventh surface. It is the same markup under
  12. * the media queries in `theme.ts`. The mockup shows a phone because that is
  13. * what most people will actually open before the morning meeting, not because it is
  14. * a different page. A separate page would drift from this one.
  15. */
  16. import { CLASS_LABELS } from '@meridian/plinth-client'
  17. import { MERIDIAN_TZ, calendarDate, localClock, localDay, localStamp, shortDate } from '@meridian/ninety-client'
  18. import { esc, join, map, orUntracked, plural } from './html.js'
  19. import { UNCATEGORIZED, bucketByTopic, type TopicBucket } from '../../../config/fathom-topics.js'
  20. import { onboardingFor, type OnboardingPlan } from '../../../config/onboarding.js'
  21. import { checkinPlaceholder } from './checkin-view.js'
  22. import { ownListPlaceholder } from './own-list-view.js'
  23. import { hasCheckin } from '../../../config/checkin.js'
  24. import { hasOwnList } from '../../../config/own-list.js'
  25. import { C, UNTRACKED_FILL } from './theme.js'
  26. import type { Briefings } from './briefings.js'
  27. import { COMPANY_VIEWER, companyBriefingKey, type Viewer } from './viewer.js'
  28. import type { Snapshot } from './collect.js'
  29. import {
  30. COVERAGE_COLUMNS,
  31. toneColor,
  32. type Drill,
  33. type MyDay,
  34. type PersonView,
  35. type QueueGroupView,
  36. type QueueRowView,
  37. type ViewModel,
  38. } from './view-model.js'
  39. const bar = (pct: string, fill: string, h = 16): string =>
  40. `<div class="bar-track" style="height:${h}px"><div class="bar-fill" style="width:${esc(pct)};background:${fill}"></div></div>`
  41. // --- 1a: the bird's-eye board -------------------------------------------------
  42. /**
  43. * A tile whose source is down shows an em-dash, not a zero.
  44. *
  45. * This is the single rule the whole page is built on and the tile is where it is
  46. * easiest to break: a confident `0` in 38px type reads as "nothing to do here"
  47. * even with a failure banner directly above it. Someone scanning the board on
  48. * their phone at 9:12 sees the number, not the banner.
  49. */
  50. function tile(opts: {
  51. key?: string
  52. label: string
  53. value: number
  54. note: string
  55. meta?: string
  56. delta?: string
  57. chip?: string
  58. variant?: 'hero' | 'accent' | ''
  59. color?: string
  60. available?: boolean
  61. }): string {
  62. const { key, label, value, note, meta, delta, chip, variant = '', color, available = true } = opts
  63. const inner = join([
  64. `<div class="tile-body">`,
  65. `<div class="tile-label">${esc(label)}</div>`,
  66. `<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px">`,
  67. available
  68. ? `<div class="tile-value"${color ? ` style="color:${color}"` : ''}>${value}</div>`
  69. : `<div class="tile-value" style="color:var(--meridian-gray-400)" title="the source this counts is not answering">&ndash;</div>`,
  70. delta && available ? `<span class="delta">${esc(delta)}</span>` : '',
  71. chip && available ? chip : '',
  72. `</div>`,
  73. `<div class="tile-note">${available ? esc(note) : 'source unavailable, not zero'}</div>`,
  74. meta && available ? `<div class="tile-note" style="margin-top:4px">${meta}</div>` : '',
  75. `</div>`,
  76. ])
  77. // The affordance row makes a drill visible before it is clicked. A context
  78. // tile gets no rule and no footer; that absence is the signal.
  79. const foot = key && available
  80. ? `<span class="tile-foot"><span>Open the ${value}</span><span aria-hidden="true">&rarr;</span></span>`
  81. : ''
  82. if (!key || !available) return `<div class="tile ${variant}"><div class="rule"></div>${inner}</div>`
  83. return `<button type="button" class="tile ${variant}" data-drill="${esc(key)}"
  84. aria-expanded="false" aria-controls="drill"><div class="rule"></div>${inner}${foot}</button>`
  85. }
  86. function drillPanel(drills: Record<string, Drill>): string {
  87. const hue = (key: string): string =>
  88. key === 'replied' ? C.magenta : key === 'design' ? C.blue : key === 'exceptions' ? C.orange : C.blue
  89. const panel = (d: Drill): string => {
  90. const count = d.rows.length + d.more
  91. return `
  92. <div class="drill" data-drill-panel="${esc(d.key)}" hidden>
  93. <div class="drill-head">
  94. <span class="n">${count}</span>
  95. <span class="t">${esc(d.title.split('·')[0]?.trim() ?? d.title)}</span>
  96. <span class="m">${esc(d.note)}</span>
  97. <button type="button" class="btn btn-secondary" data-drill-close>Close</button>
  98. </div>
  99. <div class="drill-body">
  100. ${
  101. d.rows.length
  102. ? map(
  103. d.rows,
  104. (r) => `<div class="drill-row">
  105. <div class="bar" style="background:${hue(d.key)}"></div>
  106. <div class="id">${esc(r.id)}</div>
  107. <div style="line-height:1.45">${esc(r.what)}</div>
  108. <div class="who">${esc(r.who)}</div>
  109. <div class="age">${esc(r.age)}</div>
  110. </div>`,
  111. )
  112. : '<p class="empty" style="padding:14px 18px">Nothing in this bucket right now.</p>'
  113. }
  114. ${
  115. // more not shown, on purpose, and the arithmetic is stated: rows shown
  116. // plus withheld equals the tile, so the number and the list reconcile.
  117. d.more
  118. ? `<div class="withheld"><span class="n">${d.more}</span>
  119. <span><b>more not shown, on purpose.</b> This panel lists the ${d.rows.length} oldest so it
  120. stays readable on a phone. ${d.rows.length} + ${d.more} = ${count}, which is the tile above:
  121. the number and the list always reconcile.</span></div>`
  122. : ''
  123. }
  124. </div>
  125. </div>`
  126. }
  127. return `<div id="drill">${map(Object.values(drills), panel)}</div>`
  128. }
  129. /**
  130. * The bar is a measurement: the field is fixed (25 items = 100%, a tick every
  131. * 5), so the same workload draws the same bar every day, rather than a
  132. * percentage of whoever happens to be busiest. The fill is the source mix in
  133. * legend order, so a bar answers "how much, of what" at a glance.
  134. *
  135. * Past `border-left: 2px solid` counting stops: waiting-on-client is drawn as
  136. * magenta ghost hatch: drawn because it exists, hatched because it is the
  137. * client's clock. The not-tracked hatch runs to the edge of the field, never to
  138. * a value, because absence has no magnitude.
  139. */
  140. const FIELD = 25
  141. function measureBar(p: PersonView): string {
  142. const w = (n: number | null | undefined): number => Math.min(100, ((n ?? 0) / FIELD) * 100)
  143. const segs = [
  144. { n: p.plinthOurMove, hue: C.blue, t: 'order lines' },
  145. { n: p.taskCount, hue: C.orange, t: 'Bitrix tasks' },
  146. { n: p.todos, hue: C.blueDeep, t: 'Ninety to-dos' },
  147. { n: p.sheetOurMove, hue: C.yellow, t: 'tracker items' },
  148. ].filter((x) => (x.n ?? 0) > 0)
  149. return `<div class="measure">
  150. ${map(segs, (x) => `<span class="seg" style="width:${w(x.n)}%;background:${x.hue}" title="${x.n} ${esc(x.t)}"></span>`)}
  151. ${p.plinthWaiting ? `<span class="ghost" style="width:${w(p.plinthWaiting)}%" title="${p.plinthWaiting} waiting on client; listed, never counted"></span>` : ''}
  152. ${
  153. p.plinthOurMove === null
  154. ? `<span class="rest" title="no source covers this person's orders"><span class="lbl"><span>orders not tracked</span></span></span>`
  155. : ''
  156. }
  157. </div>`
  158. }
  159. const initials = (name: string): string =>
  160. name
  161. .split(/\s+/)
  162. .map((w) => w[0] ?? '')
  163. .join('')
  164. .slice(0, 2)
  165. .toUpperCase()
  166. function personRow(p: PersonView): string {
  167. return `<div class="person">
  168. <div class="person-id">
  169. <span class="avatar" aria-hidden="true">${esc(initials(p.name))}</span>
  170. <span>
  171. <span class="person-name">${esc(p.name)}</span><br>
  172. <span class="person-role">${esc(p.role)}</span>
  173. </span>
  174. </div>
  175. <div class="person-bar">
  176. <div class="ruler" aria-hidden="true"><span>0</span><span>5</span><span>10</span><span>15</span><span>20</span><span>25</span></div>
  177. ${measureBar(p)}
  178. <div class="person-detail">${esc(p.detail)}${p.aside ? ` <span class="person-aside">· ${esc(p.aside)}</span>` : ''}</div>
  179. ${p.coverageNote ? `<div class="person-note">${esc(p.coverageNote)}</div>` : ''}
  180. </div>
  181. <div class="person-load">
  182. <div class="v">${orUntracked(p.load)}</div>
  183. <div class="k">${esc(p.loadNote)}</div>
  184. ${p.plinthOurMove === null && p.load !== null ? `<div style="margin-top:4px"><span class="untracked">+ orders</span></div>` : ''}
  185. </div>
  186. </div>`
  187. }
  188. export function board(vm: ViewModel, afterHeadline = ''): string {
  189. const h = vm.headline
  190. const orders = vm.snap.orderLines.ok
  191. const leads = vm.snap.leads.ok
  192. const exceptionsCount = vm.drills.exceptions ? vm.drills.exceptions.rows.length + vm.drills.exceptions.more : 0
  193. const d0 = vm.briefing.headlineDeltas[0]?.delta
  194. const d1 = vm.briefing.headlineDeltas[1]?.delta
  195. const deltaText = (d: { value: number; direction: string; since: string } | null | undefined): string | undefined =>
  196. d && d.direction !== 'flat'
  197. ? `${d.direction === 'up' ? '↑' : '↓'} ${Math.abs(d.value)} since ${d.since}`
  198. : undefined
  199. return join([
  200. `<section class="section">
  201. <div class="section-head">
  202. <h2>Headline</h2>
  203. <span class="hint">Only &ldquo;our move&rdquo; is workload. Everything else is context.</span>
  204. </div>
  205. <div class="tiles">
  206. ${tile({ key: 'ours', label: 'Our move', value: h.ourMove, note: 'orders + leads where Meridian owes the next step', meta: vm.drills.ours ? esc(vm.drills.ours.note.split('.')[0] ?? '') : undefined, delta: deltaText(d0), variant: 'hero', available: orders && leads })}
  207. ${tile({ key: 'design', label: 'Design queue', value: h.designQueue, note: 'in artwork, the closest proxy we have for open mockups, and a proxy is what it is', delta: deltaText(d1), color: C.blue, available: orders })}
  208. ${tile({ key: 'replied', label: 'Client replied', value: h.needsReply, note: 'they are waiting on us, nobody else. Answer these before anything below', chip: '<span class="answer-chip">Answer first</span>', variant: 'accent', color: C.magenta, available: leads })}
  209. ${tile({
  210. key: 'exceptions',
  211. label: 'Exceptions',
  212. value: exceptionsCount,
  213. note:
  214. exceptionsCount === 0
  215. ? 'payment and cancellation cases needing a human. Plinth answered and the bucket is empty, so this is free capacity, not a gap.'
  216. : 'payment and cancellation cases needing a human; each needs a decision, not a task',
  217. chip: exceptionsCount === 0 && orders ? '<span class="zero-chip">A real zero</span>' : undefined,
  218. color: exceptionsCount ? C.orange : undefined,
  219. available: orders,
  220. })}
  221. </div>
  222. <div class="ctx">
  223. <div class="ctx-key">Context,<br>not workload</div>
  224. <div class="ctx-cell">
  225. <span class="n">${orders && leads ? h.waitingOnClient : '–'}</span>
  226. <span><span class="t">Waiting on client</span>
  227. <div class="s">Follow-up list. It is the client&rsquo;s clock, not the team&rsquo;s bandwidth, so it is listed and never counted.</div></span>
  228. </div>
  229. <div class="ctx-cell">
  230. <span class="n">${orders ? h.atVendor : '–'}</span>
  231. <span><span class="t">At vendor / in transit</span>
  232. <div class="s">Delivery risk, not capacity. Counting it as backlog is how a board makes the team look behind when it is not.</div></span>
  233. </div>
  234. </div>
  235. ${drillPanel(vm.drills)}
  236. </section>`,
  237. // Injected between the numbers and the workload: the inline briefing and
  238. // the legend live here. The numbers lead, prose second, and the legend sits
  239. // directly above the first bars it explains.
  240. afterHeadline,
  241. `<section class="section">
  242. <div class="section-head" style="justify-content:space-between;width:100%">
  243. <h2>Workload by person</h2>
  244. <div class="section-head" style="margin:0;gap:8px">
  245. <span class="hint">Bar counts:</span>
  246. <button type="button" class="btn btn-outline" data-basis="ours" aria-pressed="true">our move only</button>
  247. <button type="button" class="btn btn-outline" data-basis="all" aria-pressed="false">+ waiting on client</button>
  248. </div>
  249. </div>
  250. <p class="note wide" style="margin-bottom:14px">
  251. A zero means free capacity. An untracked cell does not. Anyone no source can see says so
  252. instead of showing a number.
  253. </p>
  254. <div class="card card-pad">
  255. <div data-basis-panel="ours">${map(vm.people, personRow)}</div>
  256. <div data-basis-panel="all" hidden>${map(vm.peopleAll, personRow)}</div>
  257. <div class="legend" style="padding-top:12px">
  258. <span>Waiting-on-client is listed, never counted. It is the client&rsquo;s clock, not their bandwidth.</span>
  259. ${vm.board.offRosterAccounts.length ? `<span>Work also sits with <b>${esc(vm.board.offRosterAccounts.join(', '))}</b>, not on the roster.</span>` : ''}
  260. ${vm.board.junkTasks ? `<span>${vm.board.junkTasks} Bitrix ${plural(vm.board.junkTasks, 'task')} excluded as automation noise.</span>` : ''}
  261. </div>
  262. </div>
  263. </section>`,
  264. `<section class="section">
  265. <div class="two-up" style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
  266. <div>
  267. <div class="section-head"><h2>Orders by status</h2><span class="hint">every open product line, grouped by where it sits in production</span></div>
  268. <div class="card card-pad">
  269. <div class="legend" style="margin-bottom:14px">
  270. <span><i class="swatch" style="background:${C.blue}"></i>${esc(CLASS_LABELS.ours)}</span>
  271. <span><i class="swatch" style="background:${C.magenta}"></i>${esc(CLASS_LABELS.client)}</span>
  272. <span><i class="swatch" style="background:${C.green}"></i>${esc(CLASS_LABELS.vendor)}</span>
  273. <span><i class="swatch" style="background:${C.gray}"></i>Closed / parked</span>
  274. </div>
  275. ${
  276. vm.orderStages.length
  277. ? map(
  278. vm.orderStages,
  279. (s) => `<div class="stage-row">
  280. <div class="n" title="${esc(s.name)}">${esc(s.name)}</div>
  281. ${bar(s.pct, s.color)}
  282. <div class="c">${s.count}</div>
  283. </div>`,
  284. )
  285. : `<p class="empty">${vm.snap.orderLines.ok ? 'No order lines in the window.' : 'Plinth did not answer. This is missing, not zero.'}</p>`
  286. }
  287. </div>
  288. </div>
  289. <div>
  290. <div class="section-head"><h2>Oldest open leads</h2><span class="hint">the leads that have waited longest for anyone’s next move</span></div>
  291. <div class="card card-pad">
  292. <div class="row-head lead-cols"><div>Age</div><div class="stage">Stage</div><div>Lead</div></div>
  293. ${
  294. vm.leads.length
  295. ? map(
  296. vm.leads,
  297. (l) => `<div class="row lead-cols">
  298. <div class="num" style="color:${l.ageColor};font-weight:600">${l.age}d</div>
  299. <div class="stage" style="font-size:12px;color:var(--text-muted)">${esc(l.stage)}</div>
  300. <div title="${esc(l.company ?? '')}">${esc(l.title)}</div>
  301. </div>`,
  302. )
  303. : `<p class="empty">${vm.snap.leads.ok ? 'No dated open leads.' : 'Bitrix did not answer. This is missing, not zero.'}</p>`
  304. }
  305. <p class="note" style="margin-top:12px">
  306. Parked leads are excluded: they are paused on purpose, and including them buries the
  307. ones that are actually aging.
  308. </p>
  309. </div>
  310. </div>
  311. </div>
  312. </section>`,
  313. ])
  314. }
  315. // --- 1b: the briefing, prose --------------------------------------------------
  316. export function briefingProse(vm: ViewModel): string {
  317. const b = vm.briefing
  318. const d = b.headlineDeltas[1]?.delta
  319. const growth =
  320. d === null || d === undefined
  321. ? 'There is no earlier run to compare it against yet.'
  322. : d.direction === 'up'
  323. ? `That queue grew by ${d.value} since ${esc(d.since)} and is the biggest single thing on the board.`
  324. : d.direction === 'down'
  325. ? `That queue is down ${Math.abs(d.value)} since ${esc(d.since)}.`
  326. : `That queue has not moved since ${esc(d.since)}.`
  327. const replied = vm.drills.replied?.rows ?? []
  328. const exceptions = vm.drills.exceptions?.rows ?? []
  329. return `<div class="card">
  330. <div class="rule"></div>
  331. <div style="padding:32px 40px 12px">
  332. <div class="eyebrow">Morning Briefing</div>
  333. <h1 class="masthead" style="font-family:var(--font-display);font-size:30px;padding:0">${esc(b.dateLong)}</h1>
  334. <p class="note" style="margin-top:8px">
  335. Written at ${esc(b.generatedAt)} CT from that sync. Read before the morning meeting.
  336. </p>
  337. </div>
  338. <div class="brief" style="padding:14px 40px 0">
  339. <p class="kicker">
  340. ${
  341. // "0 things need a move from Meridian today" is the most damaging sentence
  342. // this page could produce, and an outage is exactly when it would write
  343. // it. When the sources behind the count are down, the briefing leads
  344. // with that instead of with a number.
  345. vm.briefing.headlineDeltas[0]?.available
  346. ? `<b>${vm.headline.ourMove} things need a move from Meridian today</b>, and ${vm.headline.designQueue}
  347. of them are sitting in the design queue. ${growth}`
  348. : `<b>This morning&rsquo;s counts are unavailable.</b> ${esc(vm.failures.join(' '))}
  349. Nothing below is a zero. It is a gap, and the board should not be read as
  350. &ldquo;quiet&rdquo; until the source is back.`
  351. }
  352. </p>
  353. <div style="height:1px;background:var(--border-default);margin:0 0 20px"></div>
  354. <h3>Answer first</h3>
  355. <p>
  356. ${
  357. replied.length
  358. ? `${replied.length} ${plural(replied.length, 'lead')} moved to <b>Client Replied</b>: ${esc(replied.slice(0, 2).map((r) => r.subject).join('; '))}. They are waiting on us, nobody else is.`
  359. : 'No lead is sitting in Client Replied this morning; nothing is waiting on a reply from us.'
  360. }
  361. ${
  362. exceptions.length
  363. ? ` ${exceptions.length} ${plural(exceptions.length, 'exception')} ${plural(exceptions.length, 'needs', 'need')} a decision rather than a task${
  364. exceptions.filter((e) => e.who === 'unassigned').length
  365. ? `, and ${exceptions.filter((e) => e.who === 'unassigned').length} of those have no owner`
  366. : ''
  367. }.`
  368. : ''
  369. }
  370. </p>
  371. <h3>Who is loaded</h3>
  372. <p>${map(b.loadedSentences, (s, i) => (i === b.loadedSentences.length - 1 && vm.people.some((p) => p.plinthOurMove === null) ? `<span class="quiet">${esc(s)}</span>` : `${esc(s)} `))}</p>
  373. <div class="blind">
  374. <h3>What I could not see this morning</h3>
  375. <p>${b.blindSpots.length ? esc(b.blindSpots.join(' ')) : 'Every source answered and nothing is stale.'}</p>
  376. </div>
  377. <p class="note" style="margin-bottom:6px">
  378. An open to-do does not mean unfinished work: items are only closed at Monday reviews, so
  379. &ldquo;late&rdquo; here counts review gates missed, not calendar days.
  380. </p>
  381. </div>
  382. <div class="rule" style="margin-top:20px"></div>
  383. <div style="background:var(--meridian-blue-050);padding:14px 40px;font-size:12px;display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap">
  384. <span>Generated by the briefing job · <a href="#" data-goto="board">see the full board</a></span>
  385. <span class="note">${b.comparedTo ? `Compared with ${esc(b.comparedTo)}` : 'First run, no comparison yet'}</span>
  386. </div>
  387. </div>`
  388. }
  389. // --- 1c: the briefing, data-led ------------------------------------------------
  390. function deltaTag(d: { value: number; direction: string; since: string } | null, goodWhenDown = true): string {
  391. if (!d) return `<div class="note" style="font-size:11px">no prior run</div>`
  392. if (d.direction === 'flat') return `<div class="delta flat" style="font-size:11px">no change</div>`
  393. const good = goodWhenDown ? d.direction === 'down' : d.direction === 'up'
  394. const arrow = d.direction === 'up' ? '▲' : '▼'
  395. return `<div class="delta ${good ? 'down' : 'up'}" style="font-size:11px" title="since ${esc(d.since)}">${arrow} ${Math.abs(d.value)} vs ${esc(d.since)}</div>`
  396. }
  397. /**
  398. * 1c. The same facts, scannable.
  399. *
  400. * This surface is company-visible, so it carries no meeting content: no
  401. * unowned commitments, no meeting titles. Unowned items reach the participants
  402. * of their own meeting through myDay(), and nobody else.
  403. *
  404. * Explanations like this one belong in TypeScript comments, never in the
  405. * markup: an HTML comment ships to every reader, and a `//` inside a template
  406. * literal is not a comment at all and prints onto the page.
  407. * fathom-gating.test.ts asserts against the rendered document for exactly that
  408. * reason.
  409. */
  410. export function briefingData(vm: ViewModel): string {
  411. const b = vm.briefing
  412. return `<div class="card">
  413. <div class="rule"></div>
  414. <div class="top" style="display:flex;align-items:baseline;justify-content:space-between;gap:16px;padding:22px 28px 18px;border-bottom:1px solid var(--border-default);flex-wrap:wrap">
  415. <div>
  416. <h1 style="font-family:var(--font-display);font-size:26px;color:var(--text-body)">${esc(b.dateShort)} · Morning Briefing</h1>
  417. <p class="note" style="margin-top:5px">${esc(b.generatedAt)} CT · ${b.liveSources} of ${b.totalSources} sources live</p>
  418. </div>
  419. <div style="display:flex;gap:22px;text-align:right">
  420. ${map(
  421. b.headlineDeltas,
  422. (x) => `<div>
  423. <div class="note" style="font-size:11px">${esc(x.label)}</div>
  424. ${
  425. x.available
  426. ? `<div style="font-size:26px;font-weight:600;color:${x.color}">${x.value}</div>${deltaTag(x.delta)}`
  427. : `<div style="font-size:26px;font-weight:600;color:var(--meridian-gray-400)">&ndash;</div>
  428. <div class="note" style="font-size:11px">source down</div>`
  429. }
  430. </div>`,
  431. )}
  432. </div>
  433. </div>
  434. <div style="padding:18px 28px 0">
  435. <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
  436. <span class="badge accent">Answer first</span>
  437. <span class="note">they are waiting on us</span>
  438. </div>
  439. ${
  440. b.answerFirst.length
  441. ? map(
  442. b.answerFirst,
  443. (a) => `<div class="row" style="grid-template-columns:100px 1fr 84px">
  444. <div style="font-family:var(--font-mono);font-size:12px;color:var(--meridian-blue)">${esc(a.id)}</div>
  445. <div>${esc(a.what)}</div>
  446. <div class="note" style="text-align:right">${esc(a.age)}</div>
  447. </div>`,
  448. )
  449. : '<p class="empty" style="padding-bottom:10px">Nothing is waiting on a reply from Meridian.</p>'
  450. }
  451. </div>
  452. <div style="padding:22px 28px 0">
  453. <div style="font-size:13px;font-weight:600;margin-bottom:10px">Board sizes</div>
  454. ${map(
  455. vm.people,
  456. (p) => `<div style="display:grid;grid-template-columns:150px 1fr 60px;gap:12px;align-items:center;padding:6px 0">
  457. <div style="font-size:13px">${esc(p.short)}</div>
  458. ${bar(p.pct, p.fill, 12)}
  459. <div style="text-align:right;font-size:13px">${orUntracked(p.load)}</div>
  460. </div>`,
  461. )}
  462. </div>
  463. <div style="padding:20px 28px 26px">
  464. <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px">
  465. ${map(
  466. vm.sources,
  467. (s) => `<div style="border:1px solid ${toneColor(s.tone)};border-radius:var(--radius-sm);padding:9px 11px">
  468. <div style="font-size:11px;font-weight:600">${esc(s.name)}</div>
  469. <div class="note" style="font-size:11px">${esc(s.status)}</div>
  470. </div>`,
  471. )}
  472. </div>
  473. </div>
  474. </div>`
  475. }
  476. // --- orientation ------------------------------------------------------------------
  477. /**
  478. * What the colors mean, said once, where a first-time reader starts.
  479. *
  480. * Colors on this board are identity, not severity (blue is not calmer than
  481. * magenta, it is a different kind of thing). Without a legend a reader has to
  482. * induce the scheme from examples, which is exactly the kind of insider
  483. * knowledge a board for a small team cannot assume its next new reader has.
  484. */
  485. export function legendStrip(): string {
  486. const sw = (bg: string) => `<span class="swatch" style="background:${bg}"></span>`
  487. return `<div class="card" style="padding:12px 18px;margin-bottom:14px">
  488. <details open>
  489. <summary style="cursor:pointer;font-size:13px;font-weight:600">How to read this board</summary>
  490. <div class="legend legend-grid" style="margin-top:10px">
  491. <span>${sw(C.blue)}our move: work we owe a step on</span>
  492. <span>${sw(C.magenta)}waiting on client, or past a review gate</span>
  493. <span>${sw(C.green)}at vendor: theirs, not ours</span>
  494. <span>${sw(C.orange)}Bitrix task</span>
  495. <span>${sw(C.blueDeep)}Ninety to-do</span>
  496. <span>${sw(C.yellow)}Content tracker item</span>
  497. <span>${sw(UNTRACKED_FILL)}not tracked: no source covers it (never a zero)</span>
  498. </div>
  499. <p class="note" style="margin-top:8px">
  500. Only &ldquo;our move&rdquo; counts as workload. Waiting-on-client and at-vendor are context;
  501. counting them would make the team look behind when it is not.
  502. </p>
  503. </details>
  504. </div>`
  505. }
  506. // --- 1d: my day -----------------------------------------------------------------
  507. /**
  508. * Why this list is empty, and the three reasons are not interchangeable.
  509. *
  510. * "Nothing on this board right now" printed over a Ninety 403 is the prime
  511. * invariant inverted: a source we cannot see rendered as a desk that is clear. A
  512. * person no source covers is a third thing again: a coverage gap somebody should
  513. * close, not a quiet morning.
  514. */
  515. function emptyBoard(d: MyDay): string {
  516. if (d.sourcesDown.length) {
  517. const n = d.sourcesDown.length
  518. return (
  519. `This list is incomplete, not empty: ${d.sourcesDown.join(' and ')} failed on this ` +
  520. `sync, so whatever ${plural(n, 'it holds', 'they hold')} is missing from here rather ` +
  521. `than absent.`
  522. )
  523. }
  524. if (d.person.untracked) {
  525. return (
  526. 'No source covers this person, so this list is empty because nothing can see ' +
  527. 'their work, not because there is none.'
  528. )
  529. }
  530. return 'Nothing on this board right now.'
  531. }
  532. /** Kind metadata: label, hue, and chip text colour (yellow needs dark text). */
  533. const KIND = {
  534. order: { label: 'Orders', hue: C.blue, on: '#fff' },
  535. task: { label: 'Bitrix tasks', hue: C.orange, on: '#0f141d' },
  536. todo: { label: 'Ninety to-dos', hue: C.blueDeep, on: '#fff' },
  537. tracker: { label: 'Tracker items', hue: C.yellow, on: '#0f141d' },
  538. } as const
  539. /**
  540. * "Do these three first": the whole answer to the five-seconds problem.
  541. *
  542. * The items are already sorted oldest/latest first, so the top three are the
  543. * three; this band just stops making the reader derive that from a long list.
  544. */
  545. function firstThree(d: MyDay): string {
  546. const three = d.items.slice(0, 3)
  547. if (!three.length) return ''
  548. return `<div style="padding:16px 28px 0">
  549. <div class="t-micro" style="color:var(--text-muted);margin-bottom:8px">Do these three first</div>
  550. <div class="first3">
  551. ${map(
  552. three,
  553. (w) => `<div class="f">
  554. <div class="strip" style="background:${w.color}"></div>
  555. <div class="in">
  556. <span class="age">${esc(w.age)}</span>
  557. <span class="kchip" style="background:${KIND[w.kind].hue};color:${KIND[w.kind].on}">${esc(KIND[w.kind].label.replace(/s$/, ''))}</span>
  558. ${w.id ? `<div class="id">${esc(w.id)}</div>` : '<div style="height:8px"></div>'}
  559. <div class="what">${esc(w.what)}</div>
  560. <div class="why">${esc(w.meta)}</div>
  561. </div>
  562. </div>`,
  563. )}
  564. </div>
  565. </div>`
  566. }
  567. /** One card per kind, so "what kind of work is this" stops needing the meta line. */
  568. function workByKind(d: MyDay): string {
  569. const kinds = (['order', 'task', 'todo', 'tracker'] as const)
  570. .map((k) => ({ k, rows: d.items.filter((w) => w.kind === k) }))
  571. .filter((g) => g.rows.length)
  572. if (!kinds.length) return `<p class="empty" style="padding:10px 0">${emptyBoard(d)}</p>`
  573. return map(
  574. kinds,
  575. (g) => `<div class="kind">
  576. <div class="kind-head">
  577. <span class="swatch" style="background:${KIND[g.k].hue}"></span>
  578. ${esc(KIND[g.k].label)}
  579. <span class="c">${g.rows.length}</span>
  580. </div>
  581. ${map(
  582. g.rows,
  583. (w) => `<div class="work-row"${w.noteKey ? ` data-note-key="${esc(w.noteKey)}"` : ''}>
  584. <div>
  585. <div class="work-what">${esc(w.what)}</div>
  586. <div class="work-meta">${esc(w.meta)}</div>
  587. </div>
  588. <div class="work-age">${esc(w.age)}${w.late ? '<span class="late">late</span>' : ''}</div>
  589. </div>`,
  590. )}
  591. </div>`,
  592. )
  593. }
  594. /**
  595. * Meeting action items as topic buttons, each opening its own panel, rather
  596. * than one long list at the bottom of My Day. Buckets come from
  597. * `config/fathom-topics.ts`, a hand-edited table.
  598. *
  599. * The buttons are built from `d.commitments` and nothing else, and that is a
  600. * security property rather than a convenience. `commitments` has already been
  601. * through `meetingsFor()`, which is the boundary for meeting content on this
  602. * board. Deriving a stable, board-wide set of buttons from `snap.meetings` would
  603. * disclose the existence of meetings this viewer was not in. So two people see
  604. * different buttons, and that is correct.
  605. *
  606. * A separate `data-topic` namespace, not the `data-drill` one it is modelled on:
  607. * that handler closes every `[data-drill-panel]` on the page and `drillPanel()`
  608. * hardcodes `id="drill"`, so reusing it would make opening a topic silently
  609. * close a headline tile's drill.
  610. */
  611. function topicButtons(buckets: TopicBucket<MyDay['commitments'][number]>[]): string {
  612. const total = buckets.reduce((n, b) => n + b.items.length, 0)
  613. return `<div class="topic-row">
  614. ${map(
  615. buckets,
  616. (b) => `<button type="button" class="topic-btn" data-topic="${esc(b.slug)}"
  617. aria-expanded="false" aria-controls="topic-${esc(b.slug)}">${esc(b.label)}
  618. <span class="n">${b.items.length}</span></button>`,
  619. )}
  620. </div>
  621. <p class="note" style="margin:8px 0 0">
  622. ${total} ${plural(total, 'item')} in ${buckets.length} ${plural(buckets.length, 'topic')}:
  623. every one is in exactly one, so the numbers above add up to ${total}. Topics are keyword
  624. rules in <code>config/fathom-topics.ts</code>; anything they miss lands in
  625. <b>${esc(UNCATEGORIZED)}</b> rather than being guessed at.
  626. </p>
  627. ${map(
  628. buckets,
  629. (b) => `<div class="topic-panel" id="topic-${esc(b.slug)}" data-topic-panel="${esc(b.slug)}" hidden>
  630. <div class="topic-head">
  631. <span class="n">${b.items.length}</span>
  632. <span class="t">${esc(b.label)}</span>
  633. <span class="m">${
  634. b.label === UNCATEGORIZED
  635. ? 'No keyword rule matched these. That is a gap in the map, not a category.'
  636. : 'From meetings you were in'
  637. }</span>
  638. <button type="button" data-topic-close>Close</button>
  639. </div>
  640. <div class="topic-body">
  641. ${map(
  642. b.items,
  643. (c) => `<div class="row" style="grid-template-columns:1fr auto auto">
  644. <span>${esc(c.text)}</span>
  645. <span class="note" style="white-space:nowrap">${
  646. c.unowned
  647. ? `<b style="color:${C.magenta}">nobody named</b>`
  648. : c.owner
  649. ? esc(c.owner)
  650. : c.externalOwner
  651. ? `${esc(c.externalOwner)} <span class="quiet">(external)</span>`
  652. : `<span class="quiet">${esc(c.unknownOwner ?? 'unrecognized')}</span>`
  653. }</span>
  654. <span class="note" style="white-space:nowrap"><a href="${esc(c.url)}">${esc(c.meeting.slice(0, 28))}</a> · ${esc(localStamp(c.date))} CT</span>
  655. </div>`,
  656. )}
  657. </div>
  658. </div>`,
  659. )}`
  660. }
  661. function myDayCard(
  662. d: MyDay,
  663. dateShort: string,
  664. written?: string,
  665. cutOff: string | null = null,
  666. viewer: Viewer = COMPANY_VIEWER,
  667. ): string {
  668. const p = d.person
  669. // Not `hidden`. The page carries exactly the one card the viewer may read,
  670. // so hiding it would hide the only thing there is.
  671. return `<div class="card" data-person-panel="${esc(p.name)}">
  672. <div class="rule"></div>
  673. <div class="myday-head">
  674. <div class="who">
  675. <div class="eyebrow" style="color:var(--on-slab-dim)">My Day · ${esc(dateShort)}</div>
  676. <h1>${esc(p.name)}</h1>
  677. <div class="cover">${esc(p.role)} · covered by ${esc(p.coverage.join(', ') || 'no source')}</div>
  678. </div>
  679. <div class="myday-nums">
  680. <div class="b">
  681. <div class="v">${p.load === null ? '–' : p.load}</div>
  682. <div class="k">on my board</div>
  683. </div>
  684. <div class="b">
  685. <div class="v" style="color:${d.lateCount ? C.magenta : 'inherit'}">${d.lateCount}</div>
  686. <div class="k">past a review gate</div>
  687. </div>
  688. </div>
  689. </div>
  690. ${
  691. written
  692. ? `<div style="padding:16px 28px 0">
  693. <details class="brief-inline" open>
  694. <summary><span class="caveat-chip" style="letter-spacing:.14em">Briefing</span>
  695. Written for you</summary>
  696. ${cutOff ? `<div style="padding:0 18px">${caveat(cutOff)}</div>` : ''}
  697. <div class="body prose">${map(
  698. written.split(/\n\s*\n/).filter((x) => x.trim()),
  699. (x) => `<p>${esc(x.trim()).replace(/\n/g, '<br>')}</p>`,
  700. )}</div>
  701. </details>
  702. </div>`
  703. : ''
  704. }
  705. ${firstThree(d)}
  706. <div style="padding:16px 28px 0">
  707. <div class="chips">
  708. <span class="chip solid">${d.ourMove} ${plural(d.ourMove, 'order')}</span>
  709. <span class="chip">${d.tasks} ${plural(d.tasks, 'task')}</span>
  710. <span class="chip">${d.todos} ${plural(d.todos, 'to-do')}</span>
  711. <span class="chip">${d.tracker} ${plural(d.tracker, 'tracker item')}</span>
  712. ${d.waiting ? `<span class="chip hatch">${d.waiting} waiting on client, not mine today</span>` : ''}
  713. </div>
  714. ${workByKind(d)}
  715. ${
  716. d.waiting
  717. ? `<p class="note" style="margin-top:10px">${d.waiting} order ${plural(d.waiting, 'line')} on ${esc(p.name.split(' ')[0] ?? p.name)}&rsquo;s
  718. plate ${plural(d.waiting, 'sits', 'sit')} with the client and ${plural(d.waiting, 'is', 'are')} not listed here; they are counted in the chips above and shown on The queue.</p>`
  719. : ''
  720. }
  721. ${
  722. d.todosWithheld
  723. ? `<p class="note" style="margin-top:10px">${d.todosWithheld} Ninety ${plural(d.todosWithheld, 'to-do')} ${plural(d.todosWithheld, 'is', 'are')} counted in the number above but not listed here: only the person themselves sees their own to-do list on this board.</p>`
  724. : ''
  725. }
  726. </div>
  727. <div style="padding:20px 28px 0">
  728. ${
  729. // The private list. A placeholder at sync time; the Worker swaps it for
  730. // the viewer's own lines at serve time, so no private text is ever
  731. // written into the pre-rendered page in KV.
  732. //
  733. // The ownership test is the load-bearing half, and it looks redundant.
  734. // myDaySurface filters to the viewer's own card, so today the two
  735. // conditions are the same and it is tempting to write only the second.
  736. // There is one marker pair per page and the Worker stamps the viewer's
  737. // list into it: the day anything renders a second person's card (a
  738. // manager view, a print-all, a debug flag), a placeholder sitting in
  739. // that card would put the viewer's private list under somebody else's
  740. // name. One comparison makes that impossible rather than merely absent.
  741. d.person.name === viewer.ownBriefing && hasOwnList(viewer) ? ownListPlaceholder() : ''
  742. }
  743. </div>
  744. <div style="padding:20px 28px 0">
  745. <div style="font-size:13px;font-weight:600;margin-bottom:8px">Said in a meeting</div>
  746. ${
  747. !d.commitmentsTracked
  748. ? // Not tracked, not empty. No Fathom key or a failed fetch reads as
  749. // "we did not look"; an empty list here would read as "you owe
  750. // nothing", which is the prime invariant's whole point. It must not
  751. // render as a row of zero topic buttons either: no buttons and no
  752. // key look identical, and they are opposite facts.
  753. `<div class="card" style="padding:4px 16px"><p class="empty" style="padding:10px 0">Meeting commitments are <b>not tracked</b>: no Fathom key is configured, or the fetch failed. This is not a clear desk.</p></div>`
  754. : d.commitments.length
  755. ? topicButtons(bucketByTopic(d.commitments))
  756. : `<div class="card" style="padding:4px 16px"><p class="empty" style="padding:10px 0">Nothing came out of your meetings in the window.</p></div>`
  757. }
  758. ${
  759. // A dark key is said here, not only in the source band. The list above is
  760. // short by whatever that key would have returned, and short is the one
  761. // thing this card cannot tell apart from a light fortnight on its own.
  762. d.commitmentsPartial
  763. ? `<p class="note" style="margin-top:8px;color:${C.magenta}">${esc(d.commitmentsPartial)}</p>`
  764. : ''
  765. }
  766. <p class="note" style="margin-top:8px">
  767. Every action item from meetings <b>you were in</b>, last ${14} days, not only the ones
  768. assigned to you, because you were in the room for all of them. Meetings you were not in do
  769. not appear here at all. An item with <b>nobody named</b> belongs to someone in that room;
  770. no other system will ever surface it. There is no completion state on these, so they drop
  771. off after the window whether or not they happened.
  772. </p>
  773. </div>
  774. <div style="margin-top:22px;background:var(--slab);color:var(--on-slab);padding:16px 28px;font-size:12.5px;line-height:1.6">
  775. <b>Nothing here closes outside a Monday review.</b>
  776. ${
  777. d.lateCount
  778. ? `${d.lateCount} ${plural(d.lateCount, 'item')} ${plural(d.lateCount, 'is', 'are')} past a review gate, which is what &ldquo;late&rdquo; means at Meridian, not calendar days.`
  779. : 'Nothing is past a review gate, which is what &ldquo;late&rdquo; means at Meridian, not calendar days.'
  780. }
  781. An open to-do may be finished work waiting for sign-off, so the age column counts gates missed, and says so on the row.
  782. </div>
  783. </div>`
  784. }
  785. /**
  786. * One person's day: theirs, and nobody else's.
  787. *
  788. * The page is rendered per viewer and contains one card. Every briefing
  789. * addresses its reader directly as "you", so a page holding every card with
  790. * the others hidden would let anyone read them all with view-source: hiding is
  791. * presentation, not access control. There is no person picker, because there
  792. * is nothing to pick between.
  793. */
  794. export function myDaySurface(
  795. vm: ViewModel,
  796. briefings: Briefings | null = null,
  797. viewer: Viewer = COMPANY_VIEWER,
  798. ): string {
  799. const mine = viewer.ownBriefing
  800. ? vm.myDays.filter((d) => d.person.name === viewer.ownBriefing)
  801. : []
  802. if (!mine.length) {
  803. // Two different absences, said differently. An unrecognised address is a
  804. // roster gap somebody should close; a recognised person with no card means
  805. // no source covers them, which is a coverage fact rather than a permissions
  806. // one. Neither renders as an empty board.
  807. const why = viewer.person
  808. ? `No per-person view is built for ${esc(viewer.person.name)}: no source on this ` +
  809. `board covers their work individually.`
  810. : `This view is not signed in as anyone on the roster, so there is no personal ` +
  811. `briefing to show. Company numbers, the queue and data health are all here. ` +
  812. `If you should have a personal view, ask Sam to add your address to the roster.` +
  813. ` There is no private list here either: it is per person, and this address is a ` +
  814. `shared mailbox rather than a person, and a private list on a shared mailbox is a list ` +
  815. `everyone who signs into it can read, which is the opposite of what it is for.`
  816. return join([
  817. `<section class="section">
  818. <div class="section-head"><h2>My day</h2><span class="hint">your work only: orders, tasks, to-dos and tracker items in one list</span></div>
  819. <div class="card" style="padding:22px 28px">
  820. <p class="empty">${why}</p>
  821. ${
  822. // A roster person with no card still gets their list. Every roster
  823. // person has a card today, because personViews builds from the
  824. // roster; this branch is reachable only if that ever stops being
  825. // true, and the people it would drop are the ones no source covers,
  826. // the very people the say-what-you-cannot-see rule was written
  827. // for, and exactly who most needs somewhere to write things down.
  828. hasOwnList(viewer) ? ownListPlaceholder() : ''
  829. }
  830. </div>
  831. </section>`,
  832. ])
  833. }
  834. return join([
  835. `<section class="section">
  836. <div class="section-head"><h2>My day</h2><span class="hint">your work only: orders, tasks, to-dos and tracker items in one list</span></div>
  837. ${map(mine, (d) =>
  838. myDayCard(
  839. d,
  840. vm.briefing.dateShort,
  841. briefings?.briefings?.[d.person.name],
  842. briefings ? truncatedNote(briefings, d.person.name) : null,
  843. viewer,
  844. ),
  845. )}
  846. </section>`,
  847. ])
  848. }
  849. // --- onboarding -------------------------------------------------------------------
  850. /**
  851. * The onboarding tab: a tab beside Open work holding a new person's training
  852. * material, switched off as they stop being new.
  853. *
  854. * The other surfaces render a snapshot of live sources, and their hardest job
  855. * is not turning an absence into a zero. This one renders a hand-written
  856. * document, so its hardest job is the opposite: not implying anything the
  857. * document does not say. The material is dated and attributed at the foot.
  858. * Where it is silent, this is silent.
  859. *
  860. * Visibility is decided in config/onboarding.ts, not here. `onboardingFor`
  861. * takes the viewer and returns what they may read; a viewer with nothing gets
  862. * no tab at all (render.ts omits it from the markup, the same way the inbox tab
  863. * is omitted rather than hidden). This function is never called for them, and
  864. * the empty state below exists for the one case that can happen: a plan that is
  865. * on, for a person, with nothing written in it yet.
  866. */
  867. export function onboardingSurface(viewer: Viewer = COMPANY_VIEWER): string {
  868. const plans = onboardingFor(viewer)
  869. if (!plans.length) {
  870. // Reachable only by a direct call; the tab is not rendered without plans.
  871. return `<section class="section">
  872. <div class="section-head"><h2>Onboarding</h2></div>
  873. <div class="card" style="padding:22px 28px">
  874. <p class="empty">Nobody is being onboarded right now.</p>
  875. <p class="note" style="margin-top:8px">This tab appears while someone's plan is switched
  876. on in <code>config/onboarding.ts</code>. Switching it off keeps the material and hides
  877. the tab.</p>
  878. </div>
  879. </section>`
  880. }
  881. return `<section class="section">
  882. <div class="section-head"><h2>Onboarding</h2>
  883. <span class="hint">training material, kept as it comes in</span>
  884. ${
  885. // A short recap of what has been covered so far. Linked from here
  886. // rather than being a tab, because it is a view of this material and
  887. // not another place to look. Everyone who
  888. // can see this tab can open it: it carries training topics only, so
  889. // there is nothing on it they cannot already read below.
  890. `<a class="hint" href="/onboarding/export" style="margin-left:auto">Training recap &rarr;</a>`
  891. }</div>
  892. ${
  893. // The end-of-day note goes first, above the material. It is the one thing
  894. // on this tab somebody has to do today; the plan below it is reference the
  895. // trainee has already read. Rendered as a placeholder the Worker fills in at serve
  896. // time, because a card baked into a nightly render would show a form for a
  897. // day that had not started yet (see checkin-view.ts).
  898. //
  899. // It is omitted entirely for anyone with neither grant, which is not the
  900. // same list as the people who can see this tab (readers of Leadership
  901. // to-dos see the plan). Nothing is emitted for them: not a hidden div,
  902. // nothing.
  903. hasCheckin(viewer) ? checkinPlaceholder() : ''
  904. }
  905. ${map(plans, (p) => onboardingCard(p, viewer))}
  906. </section>`
  907. }
  908. /**
  909. * "Sep 14" from a bare calendar date. Defined in @meridian/ninety-client alongside
  910. * every other date helper, and re-exported here because two surfaces format
  911. * bare calendar dates now, and because this is the import path the tests and
  912. * the rest of this app already use.
  913. */
  914. export { calendarDate }
  915. /** Day 1 on the start date, and never day 0: a person's first day is their first. */
  916. function dayNumber(startedOn: string, now: Date): number | null {
  917. const start = Date.parse(`${startedOn}T00:00:00Z`)
  918. if (Number.isNaN(start)) return null
  919. const today = Date.parse(`${localDay(now.toISOString())}T00:00:00Z`)
  920. return Math.max(1, Math.round((today - start) / 86_400_000) + 1)
  921. }
  922. function onboardingCard(plan: OnboardingPlan, viewer: Viewer, now: Date = new Date()): string {
  923. const day = dayNumber(plan.startedOn, now)
  924. const isMine = plan.person === viewer.person?.name
  925. return `<div class="card" style="margin-bottom:18px">
  926. <div class="rule"></div>
  927. <div style="padding:22px 28px 16px;border-bottom:1px solid var(--border-default)">
  928. <div class="eyebrow">${esc(plan.role)}${plan.trainedBy ? ` &middot; trained by ${esc(plan.trainedBy)}` : ''}</div>
  929. <h1 style="font-family:var(--font-display);font-size:26px;color:var(--text-body)">
  930. ${esc(isMine ? 'Your first weeks' : `${plan.person}: onboarding`)}
  931. </h1>
  932. <p class="note" style="margin-top:6px">
  933. Started ${esc(calendarDate(plan.startedOn))}${day === null ? '' : ` &middot; day ${day}`}.
  934. ${
  935. // Say what is shown, in its smallest form: a reader looking at somebody else's
  936. // training should know that is what they are looking at.
  937. isMine ? '' : `Shown to you because you read the Leadership Team's to-dos.`
  938. }
  939. </p>
  940. <p style="margin:10px 0 0;font-size:13px;color:var(--meridian-gray-700);max-width:78ch">${esc(plan.summary)}</p>
  941. </div>
  942. <div style="padding:6px 28px 0">
  943. ${map(plan.days, (d) => onboardingDay(d))}
  944. ${
  945. plan.days.length
  946. ? ''
  947. : `<p class="empty" style="padding:16px 0">Nothing has been added to this plan yet.
  948. That is an empty file, not an empty first week. The material goes into
  949. <code>config/onboarding.ts</code> as it comes in.</p>`
  950. }
  951. ${onboardingResources(plan.resources)}
  952. </div>
  953. <p class="note wide" style="padding:16px 28px 24px;border-top:1px solid var(--hairline)">
  954. <b>Where this came from:</b> ${esc(plan.sources.join(' · '))}.
  955. The SOPs are the source of truth: where this and an SOP disagree, follow the SOP and
  956. tell Sam. This page is a config file
  957. (<code>config/onboarding.ts</code>), so it changes when somebody edits it and not before.
  958. </p>
  959. </div>`
  960. }
  961. /**
  962. * One day, or one standing group: a collapsible holding collapsible topics.
  963. *
  964. * `<details>` and not a script, so both levels fold without JS. The board ships as one self-contained `dashboard.html` that is also served by
  965. * the Worker, and a disclosure widget the browser already implements needs no
  966. * JS, survives a file:// open, keeps its state through a print, and is
  967. * keyboard-navigable without anybody writing that. The `open` attribute is the
  968. * data's, not the renderer's; see the field comment in config/onboarding.ts.
  969. *
  970. * What the summary has to carry: a folded thing is a promise about what is
  971. * inside it, so the day says how many topics it holds and each topic says how
  972. * many points, counted off the very items rendered below, never off anything
  973. * else. A heading you have not opened should still be able to account for
  974. * what it is hiding.
  975. */
  976. function onboardingDay(day: {
  977. day: number | null
  978. date: string | null
  979. title: string
  980. intro?: string
  981. open?: boolean
  982. recordings?: { label: string; href: string; note?: string }[]
  983. sections: {
  984. title: string
  985. intro?: string
  986. bullets?: string[]
  987. rows?: { label: string; detail: string; tag?: string }[]
  988. }[]
  989. }): string {
  990. // A day carries its number and date; standing material carries neither, and
  991. // says so rather than borrowing a day's vocabulary for something that is true
  992. // every day. "Ongoing" and not "Every day": the first standing group is
  993. // titled "Every day: the operating rhythm", and an eyebrow that repeats the
  994. // heading it labels is noise in the one place a scanner is relying on it.
  995. const eyebrow =
  996. day.day === null
  997. ? 'Ongoing'
  998. : `Day ${String(day.day).padStart(2, '0')}${day.date ? ` &middot; ${esc(calendarDate(day.date))}` : ''}`
  999. const topics = day.sections.length
  1000. return `<details class="ob-day"${day.open ? ' open' : ''}>
  1001. <summary class="ob-day-sum">
  1002. <span class="ob-day-eyebrow">${eyebrow}</span>
  1003. <span class="ob-day-title">${esc(day.title)}</span>
  1004. <span class="ob-count">${topics} ${topics === 1 ? 'topic' : 'topics'}</span>
  1005. </summary>
  1006. <div class="ob-day-body">
  1007. ${day.intro ? `<p class="ob-intro">${esc(day.intro)}</p>` : ''}
  1008. ${
  1009. day.recordings?.length
  1010. ? `<p class="ob-rec">${map(
  1011. day.recordings,
  1012. (r) =>
  1013. `<a href="${esc(r.href)}" target="_blank" rel="noreferrer noopener">${esc(r.label)}</a>`,
  1014. )}</p>`
  1015. : ''
  1016. }
  1017. ${map(day.sections, (sec) => onboardingSection(sec))}
  1018. ${
  1019. topics
  1020. ? ''
  1021. : `<p class="empty">Nothing written under this one yet.</p>`
  1022. }
  1023. </div>
  1024. </details>`
  1025. }
  1026. function onboardingSection(sec: {
  1027. title: string
  1028. intro?: string
  1029. bullets?: string[]
  1030. rows?: { label: string; detail: string; tag?: string }[]
  1031. }): string {
  1032. // Counted off exactly what is rendered underneath, so the two can always
  1033. // account for each other.
  1034. const points = (sec.bullets?.length ?? 0) + (sec.rows?.length ?? 0)
  1035. return `<details class="ob-topic">
  1036. <summary class="ob-topic-sum">
  1037. <span class="ob-title">${esc(sec.title)}</span>
  1038. <span class="ob-count">${points}</span>
  1039. </summary>
  1040. <div class="ob-topic-body">
  1041. ${sec.intro ? `<p class="ob-intro">${esc(sec.intro)}</p>` : ''}
  1042. ${
  1043. sec.bullets?.length
  1044. ? `<ul class="ob-list">${map(sec.bullets, (b) => `<li>${esc(b)}</li>`)}</ul>`
  1045. : ''
  1046. }
  1047. ${
  1048. sec.rows?.length
  1049. ? map(
  1050. sec.rows,
  1051. (r) => `<div class="ob-row">
  1052. <div class="ob-label">${esc(r.label)}${r.tag ? `<span class="ob-tag">${esc(r.tag)}</span>` : ''}</div>
  1053. <div class="ob-detail">${esc(r.detail)}</div>
  1054. </div>`,
  1055. )
  1056. : ''
  1057. }
  1058. ${points ? '' : `<p class="empty">Nothing under this heading yet.</p>`}
  1059. </div>
  1060. </details>`
  1061. }
  1062. /**
  1063. * The master resources list, at the foot of the plan.
  1064. *
  1065. * Not collapsed. Everything above it folds because it is material you read once
  1066. * and then skip; this is the part you come back to mid-task, and a link you
  1067. * have to expand twice to reach is a link you stop using. It renders open, and
  1068. * it renders nothing at all when there are no links; an empty "Resources"
  1069. * heading promises a list that does not exist.
  1070. */
  1071. function onboardingResources(groups: { title: string; links: { label: string; href: string; note?: string }[] }[]): string {
  1072. const withLinks = groups.filter((g) => g.links.length)
  1073. if (!withLinks.length) return ''
  1074. return `<div class="ob-resources">
  1075. <h3 class="ob-res-head">Resources</h3>
  1076. <p class="ob-intro">Everything linked anywhere on this plan, in one place.</p>
  1077. ${map(
  1078. withLinks,
  1079. (g) => `<div class="ob-res-group">
  1080. <h4 class="ob-res-title">${esc(g.title)}</h4>
  1081. ${map(
  1082. g.links,
  1083. (l) => `<div class="ob-row">
  1084. <div class="ob-label"><a href="${esc(l.href)}" target="_blank" rel="noreferrer noopener">${esc(l.label)}</a></div>
  1085. <div class="ob-detail">${esc(l.note ?? '')}</div>
  1086. </div>`,
  1087. )}
  1088. </div>`,
  1089. )}
  1090. </div>`
  1091. }
  1092. // --- 1e: what moved elsewhere ------------------------------------------------------
  1093. export function elsewhere(vm: ViewModel): string {
  1094. return `<div class="card">
  1095. <div class="rule"></div>
  1096. <div style="padding:22px 28px 16px;border-bottom:1px solid var(--border-default)">
  1097. <div class="eyebrow">Elsewhere</div>
  1098. <h1 style="font-family:var(--font-display);font-size:26px;color:var(--text-body)">What moved outside your lane</h1>
  1099. <p style="margin:6px 0 0;font-size:13px;color:var(--meridian-gray-700);max-width:78ch">
  1100. Last 24 hours. Neither Plinth nor Bitrix keeps an event log, so this is built from the
  1101. timestamps that do exist (<code>updatedAt</code> on a lead, the order date on a line)
  1102. plus standing facts worth knowing whenever they changed. It is an honest
  1103. &ldquo;what looks new&rdquo;, not a changelog, and anything the source does not timestamp is
  1104. listed with the time left blank rather than with one invented for it.
  1105. </p>
  1106. </div>
  1107. <div style="padding:6px 28px 0">
  1108. ${
  1109. vm.feed.length
  1110. ? map(
  1111. vm.feed,
  1112. (f) => `<div class="feed-row">
  1113. <div>
  1114. <span class="badge ${f.tone}">${esc(f.dept)}</span>
  1115. <div class="feed-time">${esc(f.time)}</div>
  1116. </div>
  1117. <div>
  1118. <div class="feed-text">${esc(f.text)}</div>
  1119. <div class="feed-why">${esc(f.why)}</div>
  1120. </div>
  1121. </div>`,
  1122. )
  1123. : '<p class="empty" style="padding:16px 0">Nothing moved in the last 24 hours that another department would need to know.</p>'
  1124. }
  1125. </div>
  1126. <p class="note" style="padding:16px 28px 24px">
  1127. Nothing here needs an action from you. It exists so that Growth does not hear about a delivered
  1128. order two weeks after Client Success closed it.
  1129. </p>
  1130. </div>`
  1131. }
  1132. // --- 1f: data health ----------------------------------------------------------------
  1133. export function dataHealth(vm: ViewModel): string {
  1134. const gaps = vm.coverage.filter((r) => r.cells.some((c) => c.text === 'not tracked'))
  1135. return `<div class="card">
  1136. <div class="rule"></div>
  1137. <div style="padding:22px 30px 18px;border-bottom:1px solid var(--border-default)">
  1138. <div class="eyebrow">Data Health</div>
  1139. <h1 style="font-family:var(--font-display);font-size:26px;color:var(--text-body)">What this board can see</h1>
  1140. <p style="margin:6px 0 0;font-size:13px;color:var(--meridian-gray-700);max-width:74ch">
  1141. Every number on the dashboard traces back to one of these. A source that fails shows as
  1142. failed. Zero and broken must never look the same.
  1143. </p>
  1144. </div>
  1145. <div style="padding:8px 30px 0">
  1146. ${map(
  1147. vm.sources,
  1148. (s) => `<div class="source-row">
  1149. <div class="health ${s.tone === 'failed' ? 'failed' : s.tone === 'degraded' || s.tone === 'absent' ? 'stale' : /read/i.test(s.status) ? 'read' : 'live'}"
  1150. title="${esc(s.status)}"></div>
  1151. <div>
  1152. <div class="source-name">${esc(s.name)}</div>
  1153. <div class="source-covers">${esc(s.covers)}</div>
  1154. </div>
  1155. <div class="source-note">${esc(s.note)}</div>
  1156. <div class="source-status">${esc(s.status)}</div>
  1157. </div>`,
  1158. )}
  1159. </div>
  1160. <div style="padding:22px 30px 0">
  1161. <h2 style="font-size:14px;font-weight:600">People coverage</h2>
  1162. <p class="note" style="margin:4px 0 12px">
  1163. "Covered" means the source can see this person, not that they have work. Somebody covered
  1164. with nothing open is idle; somebody not tracked is invisible, and those must never look the
  1165. same. Ninety is the only source here that sees everyone. Plinth sees only people holding
  1166. order lines, and Bitrix tasks only people with tasks.
  1167. </p>
  1168. <div class="matrix">
  1169. <div class="matrix-head">
  1170. <div>Person</div>${map([...COVERAGE_COLUMNS], (h) => `<div>${esc(h)}</div>`)}
  1171. </div>
  1172. ${map(
  1173. vm.coverage,
  1174. (r) => `<div class="matrix-row">
  1175. <div>${esc(r.name)}</div>
  1176. ${map(r.cells, (c) =>
  1177. c.text === 'covered'
  1178. ? `<div class="cell-covered" style="font-size:12px"><i aria-hidden="true"></i>covered</div>`
  1179. : c.text === '–'
  1180. ? `<div style="font-size:12px;color:var(--meridian-gray-400)">not applicable</div>`
  1181. : `<div><span class="cell-untracked">${esc(c.text)}</span></div>`,
  1182. )}
  1183. </div>`,
  1184. )}
  1185. </div>
  1186. ${
  1187. gaps.length
  1188. ? `<div class="caveat" style="margin-top:16px">
  1189. <b>${esc(gaps.map((g) => g.name.split(' ')[0]).join(' and '))} hold no Plinth assignment.</b>
  1190. <span>Their order work is invisible here. Their small numbers are a coverage gap, not
  1191. spare capacity. Do not route work off that row.</span>
  1192. </div>`
  1193. : ''
  1194. }
  1195. </div>
  1196. <div style="padding:20px 30px 26px">
  1197. <h2 style="font-size:14px;font-weight:600;margin-bottom:10px">Known blind spots</h2>
  1198. <div class="blindspots">
  1199. <div class="blindspot"><div class="strip"></div>
  1200. <h3>Mockups in flight</h3>
  1201. <p>No system records it. The design queue (${vm.headline.designQueue}) is the closest proxy
  1202. and it is not the same thing. Ask the designers.</p>
  1203. </div>
  1204. <div class="blindspot"><div class="strip"></div>
  1205. <h3>Ninety rocks and to-dos</h3>
  1206. <p>One token sees one person. A company view needs one token per person. That is the
  1207. API&rsquo;s shape, not a workaround.</p>
  1208. </div>
  1209. <div class="blindspot"><div class="strip"></div>
  1210. <h3>Scorecard / KPI trends</h3>
  1211. <p>Write-only in the Ninety API. There is no way to read values back.</p>
  1212. </div>
  1213. <div class="blindspot"><div class="strip"></div>
  1214. <h3>A future CRM change</h3>
  1215. <p>Nothing here assumes Bitrix is permanent. The stage taxonomy is one config file, so a CRM
  1216. swap is a rewrite of that file, not of the board.</p>
  1217. </div>
  1218. </div>
  1219. </div>
  1220. </div>`
  1221. }
  1222. // --- 1g: the queue ------------------------------------------------------------
  1223. /**
  1224. * Order numbers, grouped under whoever is holding them.
  1225. *
  1226. * This is the surface a designer opens. Everything else on this dashboard
  1227. * describes the day; this one lists the work. The rules it is built on:
  1228. *
  1229. * Real tables, not divs. Screen readers announce row and column position from
  1230. * a <table> with proper headers and get nothing from a grid of divs. Someone
  1231. * navigating this by keyboard needs to hear "row 4, order MD2639511" rather
  1232. * than a stream of unlabelled text.
  1233. *
  1234. * Age is not conveyed by colour. An aging row gets a text marker as well as
  1235. * weight, because "the red ones are old" is not information for a colourblind
  1236. * reader or a printed page.
  1237. *
  1238. * Absence is a value. "unassigned" and "product not in export" are printed
  1239. * words. A blank cell in a designer column reads as "no designer needed",
  1240. * which is the exact opposite of what it means.
  1241. *
  1242. * No decorative numbers. Every figure on this surface is something a person
  1243. * can act on or verify. A count nobody can do anything with is noise, and
  1244. * noise is what makes people stop reading a dashboard.
  1245. */
  1246. export function queueSurface(vm: ViewModel): string {
  1247. const q = vm.queue
  1248. if (!q.available) {
  1249. return `<section class="card">
  1250. <div class="rule"></div>
  1251. <div style="padding:22px 28px 20px">
  1252. <div class="eyebrow">The Queue</div>
  1253. <p class="empty">${esc(q.reason)}</p>
  1254. </div>
  1255. </section>`
  1256. }
  1257. // The age system: the number, then a 3px bar over a fixed 60-day field with a
  1258. // hairline at 30, so 54d and 5d differ before either number is read. The flag
  1259. // stays a word, because the page prints and roughly one in twelve men reading
  1260. // it will not see the magenta.
  1261. const ageCell = (r: QueueRowView): string => {
  1262. if (r.ageDays === null) return `<td class="age-cell">${esc(r.age)}</td>`
  1263. const pct = Math.min(100, (r.ageDays / 60) * 100)
  1264. return `<td class="age-cell" data-label="Age">
  1265. <span class="d">${r.ageDays}</span><span class="u">d</span>
  1266. <div class="age-track" aria-hidden="true"><i style="width:${pct.toFixed(1)}%"></i></div>
  1267. ${r.aging ? '<span class="aging-flag">aging</span>' : ''}
  1268. </td>`
  1269. }
  1270. const row = (r: QueueRowView): string => `
  1271. <tr${r.aging ? ' class="aging"' : ''} data-note-key="order:${esc(r.orderNumber)}" style="box-shadow:inset 3px 0 0 ${r.statusColor}">
  1272. <th scope="row" class="ordnum" style="padding-left:12px">${esc(r.orderNumber)}</th>
  1273. <td data-label="Product">${esc(r.product)}</td>
  1274. <td data-label="Status"><span class="status-dot" style="background:${r.statusColor}" aria-hidden="true"></span>${esc(r.status)}</td>
  1275. <td data-label="Client">${esc(r.customer)}</td>
  1276. ${ageCell(r)}
  1277. </tr>`
  1278. const group = (g: QueueGroupView): string => `
  1279. <section class="queue-group${g.unassigned ? ' queue-unassigned' : ''}"
  1280. aria-labelledby="q-${esc(g.designer.replace(/\W+/g, '-').toLowerCase())}">
  1281. <h3 id="q-${esc(g.designer.replace(/\W+/g, '-').toLowerCase())}" class="queue-head">
  1282. ${g.unassigned ? '' : `<span class="avatar" aria-hidden="true">${esc(g.designer.split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase())}</span>`}
  1283. ${esc(g.designer)}
  1284. <span class="queue-sub">${esc(g.summary)}</span>
  1285. ${g.rows.filter((r) => r.aging).length ? `<span class="queue-aging-count">${g.rows.filter((r) => r.aging).length} aging</span>` : ''}
  1286. </h3>
  1287. <table class="queue-table">
  1288. <caption class="sr-only">Order lines held by ${esc(g.designer)}, oldest first</caption>
  1289. <thead>
  1290. <tr>
  1291. <th scope="col">Order</th>
  1292. <th scope="col">Product</th>
  1293. <th scope="col">Status</th>
  1294. <th scope="col">Client</th>
  1295. <th scope="col">Age</th>
  1296. </tr>
  1297. </thead>
  1298. <tbody>${map(g.rows, row)}</tbody>
  1299. </table>
  1300. </section>`
  1301. return join([
  1302. // Same header anatomy as Elsewhere and Data Health (rule, eyebrow, display
  1303. // title, muted lede), so every tab looks like the same product.
  1304. `<section class="card">
  1305. <div class="rule"></div>
  1306. <div style="padding:22px 28px 20px">
  1307. <div class="eyebrow">The Queue</div>
  1308. <h1 style="font-family:var(--font-display);font-size:26px;color:var(--text-body)">Order lines we owe the next move on</h1>
  1309. <p style="margin:6px 0 0;font-size:13px;color:var(--meridian-gray-700);max-width:78ch">
  1310. Oldest first, grouped by the designer holding them. Order numbers are the ones you
  1311. can paste into Plinth. Lines waiting on a client or sitting with a vendor are
  1312. deliberately not here.</p>
  1313. ${
  1314. // When lines are unassigned, the magenta notice below carries that fact
  1315. // with the fix attached; repeating the same sentence here word for
  1316. // word would read like a rendering bug.
  1317. q.unassignedCount ? '' : `<p class="note" style="margin-top:8px">${esc(q.reason)}</p>`
  1318. }
  1319. ${q.everythingIsOld ? `<p class="note" style="margin-top:8px;color:${C.magenta}">${esc(q.everythingIsOld)}</p>` : ''}
  1320. </div>
  1321. </section>`,
  1322. // Unassigned lists last so it never reads as the team's biggest queue, and
  1323. // it gets the notice above. It means Plinth itself holds no assignee.
  1324. q.unassignedCount
  1325. ? `<div class="unassigned-notice"><span class="n">${q.unassignedCount}</span>
  1326. <span><b>of ${q.total} order lines carry no designer.</b> Plinth returned <code>--</code> for these
  1327. lines. They are genuinely unassigned, per line, straight from the API. The fix is to assign them in Plinth;
  1328. there is nothing to re-export. This group lists last, so it never reads as the
  1329. team&rsquo;s backlog.</span></div>`
  1330. : '',
  1331. q.groups.length
  1332. ? map([...q.groups.filter((g) => !g.unassigned), ...q.groups.filter((g) => g.unassigned)], group)
  1333. : '<section class="card"><p class="empty">Nothing is waiting on this team right now.</p></section>',
  1334. ])
  1335. }
  1336. // --- the written briefing ------------------------------------------------------
  1337. /**
  1338. * Turn the model's plain prose into paragraphs. It is instructed to write plain
  1339. * sentences with no markup, so this only splits on blank lines and escapes.
  1340. */
  1341. const asProse = (text: string): string =>
  1342. map(
  1343. text.split(/\n\s*\n/).filter((p) => p.trim()),
  1344. (p) => `<p>${esc(p.trim()).replace(/\n/g, '<br>')}</p>`,
  1345. )
  1346. /**
  1347. * A stale pairing is worse than a missing one, but say which kind.
  1348. *
  1349. * The briefing is written from one sync. If the board has re-synced since, the
  1350. * prose describes numbers that are no longer the ones beside it, and a reader
  1351. * has no way to tell. So a mismatch is stated rather than styled around.
  1352. *
  1353. * Two messages rather than one, because the board re-syncs every 15 minutes
  1354. * while the prose is authored once each morning, so a gap is the normal state
  1355. * from about 06:15 onward. A single "re-run the briefing" warning would fire
  1356. * all day and ask for something nobody wants (a model call every quarter
  1357. * hour), and a warning that is always on stops being read.
  1358. *
  1359. * So: prose from earlier today is expected and reads as a note about scope.
  1360. * Prose from an earlier day means the morning brief did not run, which is a real
  1361. * problem with a real fix. Only the second one asks for anything.
  1362. */
  1363. function briefingAge(b: Briefings, snap: Snapshot): string | null {
  1364. if (!b.payloadGeneratedAt) return null
  1365. if (b.payloadGeneratedAt === snap.syncedAt) return null
  1366. const writtenAt = clock(b.payloadGeneratedAt)
  1367. if (localDay(b.payloadGeneratedAt) === localDay(snap.syncedAt)) {
  1368. return (
  1369. `Written at ${writtenAt} from that sync's numbers. The counts on the rest of this page ` +
  1370. `refresh every 15 minutes, so they have moved since. This reads the morning, not the ` +
  1371. `minute.`
  1372. )
  1373. }
  1374. return (
  1375. `Written ${shortDate(b.payloadGeneratedAt)} at ${writtenAt}, an EARLIER DAY, not this ` +
  1376. `morning. The numbers beside it are today's. Re-run \`pnpm morning\` before trusting it.`
  1377. )
  1378. }
  1379. const clock = (iso: string): string =>
  1380. new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, hour: 'numeric', minute: '2-digit' }).format(
  1381. new Date(iso),
  1382. )
  1383. /**
  1384. * The caveat for a briefing the model did not finish.
  1385. *
  1386. * Kept separate from briefingAge() because the two say different things and can
  1387. * both be true at once: one is "written from older numbers", the other is
  1388. * "written, but cut off mid-thought". A reader needs to know which.
  1389. */
  1390. function truncatedNote(b: Briefings, key: string): string | null {
  1391. if (!b.truncated?.includes(key)) return null
  1392. return (
  1393. 'Cut off at the model output limit. This briefing stops mid-thought and ' +
  1394. 'the rest of it was never written. Re-run the briefing to get all of it.'
  1395. )
  1396. }
  1397. /** The `caveat down` treatment used for both briefing caveats. */
  1398. function caveat(text: string): string {
  1399. return `<p class="caveat down" style="margin:10px 0">${esc(text)}</p>`
  1400. }
  1401. /**
  1402. * The company briefing, inline at the top of Open work, read where the work
  1403. * is, per the approved IA. A real <details open>, so it collapses with
  1404. * scripting off. The Briefing tab keeps the full version as archive.
  1405. *
  1406. * The staleness caveat names the specific conflict rather than a vague "may be
  1407. * stale": prose that says one number beside tiles that say another is the
  1408. * fastest way to lose a reader's trust in both.
  1409. */
  1410. export function inlineBriefing(
  1411. b: Briefings | null,
  1412. snap: Snapshot,
  1413. viewer: Viewer = COMPANY_VIEWER,
  1414. ): string {
  1415. const text = b?.briefings?.[companyBriefingKey(viewer)]
  1416. if (!b || !text) return ''
  1417. const stale = briefingAge(b, snap)
  1418. const firstPara = text.split(/\n\s*\n/)[0] ?? text
  1419. return `<details class="brief-inline" open>
  1420. <summary><span class="caveat-chip" style="letter-spacing:.14em">Briefing</span>
  1421. This morning, in prose <span class="hint">· ${esc(localClock(b.generatedAt))} CT · full version on the Briefing tab</span></summary>
  1422. ${stale ? `<div style="padding:0 18px">${caveat(stale)}</div>` : ''}
  1423. <div class="body">${esc(firstPara.trim()).replace(/\n/g, '<br>')}</div>
  1424. </details>`
  1425. }
  1426. /**
  1427. * The model-written briefing, shown above the generated one.
  1428. *
  1429. * Both are on the page because they are different artifacts and only one of
  1430. * them can be wrong in an interesting way. The block below this is assembled
  1431. * from the numbers by `briefingProse()`. It cannot hallucinate but it also
  1432. * cannot notice anything. This one is written by a model and is the one to
  1433. * read critically. Keeping them adjacent means a drift between them is visible.
  1434. */
  1435. export function writtenBriefing(
  1436. b: Briefings | null,
  1437. snap: Snapshot,
  1438. viewer: Viewer = COMPANY_VIEWER,
  1439. ): string {
  1440. // Two company briefs exist because prose cannot be redacted after a model
  1441. // writes it. `company` names Leadership Team to-dos by title; the all-hands one
  1442. // is generated from a payload with them removed. The viewer decides which,
  1443. // and there is no fallback from one to the other: an absent all-hands brief
  1444. // renders as absent rather than quietly serving the full one.
  1445. const companyKey = companyBriefingKey(viewer)
  1446. const text = b?.briefings?.[companyKey]
  1447. if (!b || !text) {
  1448. return `<section class="card">
  1449. <div class="rule"></div>
  1450. <div style="padding:22px 28px 20px">
  1451. <div class="eyebrow">Written Briefing</div>
  1452. <p class="empty">Not generated for this sync. Run
  1453. <code>python3 analysis/brief.py</code> (or <code>pnpm morning</code>, which
  1454. syncs, writes every briefing, and re-renders this page).</p>
  1455. </div>
  1456. </section>`
  1457. }
  1458. const stale = briefingAge(b, snap)
  1459. const cutCompany = truncatedNote(b, companyKey)
  1460. const cutExec = truncatedNote(b, 'exec')
  1461. // An eyebrow, not a second display h2: the tab header above this card already
  1462. // says "Briefing" in 27px caps.
  1463. return `<section class="card">
  1464. <div class="rule"></div>
  1465. <div style="padding:22px 28px 24px">
  1466. <div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap">
  1467. <div class="eyebrow" style="margin-bottom:0">Written Briefing</div>
  1468. <span class="note">${esc(b.model)} · ${esc(localClock(b.generatedAt))} CT</span>
  1469. </div>
  1470. ${stale ? caveat(stale) : ''}
  1471. ${cutCompany ? caveat(cutCompany) : ''}
  1472. <div class="prose" style="margin-top:12px">${asProse(text)}</div>
  1473. ${
  1474. // Enforced, not styled: the exec brief is omitted from the markup for
  1475. // everyone without seesExec: not collapsed, not hidden. A <details> element is open to
  1476. // anyone who clicks it, and its contents are in the page either way.
  1477. viewer.seesExec && b.briefings.exec
  1478. ? `<details style="margin-top:16px">
  1479. <summary style="cursor:pointer;font-weight:600;font-size:13px">Leadership view</summary>
  1480. ${cutExec ? caveat(cutExec) : ''}
  1481. <div class="prose" style="margin-top:10px">${asProse(b.briefings.exec)}</div>
  1482. </details>`
  1483. : ''
  1484. }
  1485. </div>
  1486. </section>`
  1487. }