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

onboarding-export.ts

Language TypeScript22.3 KB560 lines

apps/dashboard/src/onboarding-export.ts

  1. /**
  2. * "What have you covered with her?": the onboarding material as a few bullets
  3. * somebody can paste into a chat.
  4. *
  5. * "How is the new hire's training going?" gets asked most mornings, and the
  6. * answer wanted is a few short bullets on what she has been trained on and
  7. * what she is able to do. The material is already on the board and already
  8. * dated; this is a form of it that survives being pasted into a message.
  9. *
  10. * What is in it, and what is deliberately not: training material written by
  11. * the trainer in config/onboarding.ts. Not the trainee's end-of-day notes,
  12. * which are her own words, written knowing exactly who reads them. This export
  13. * exists to be forwarded to people who are not on that reader list, so
  14. * anything in it is effectively theirs to see.
  15. *
  16. * So the rule here is not a filter, it is a source: this module reads
  17. * `config/onboarding.ts` and nothing else. It cannot leak a note because it
  18. * cannot see one. A test asserts the import graph stays that way, because "we
  19. * were careful" is not a control and the next person adding "just the
  20. * accomplishments" will not read this comment first.
  21. *
  22. * That is also why it is readable by everyone who can see the tab: anyone who
  23. * asks the morning question can run it themselves.
  24. *
  25. * Four things that must not look alike, which is this board's whole discipline
  26. * pointed at prose:
  27. * - a window with no sessions in it → says so, and names the last one
  28. * - a plan with no sessions at all → says that instead
  29. * - standing material, which has no date and cannot be in a window → counted
  30. * and named as withheld, never silently dropped (rule 9)
  31. * - a window the reader did not choose → said out loud, never passed off as
  32. * theirs (a bad `?days=` falls back, and the page admits it)
  33. *
  34. * Runtime-neutral (rule 7): the Worker serves this, so no `node:` imports.
  35. */
  36. import { calendarDate, calendarDateLong, localDay } from '@meridian/ninety-client'
  37. import type { OnboardingDay, OnboardingPlan } from '../../../config/onboarding.js'
  38. import { esc, map } from './html.js'
  39. /**
  40. * The windows offered as buttons. Not a free number box: every value here is
  41. * one somebody would actually ask for, and a free box invites `?days=99999`
  42. * to mean something different from `all` while looking the same.
  43. */
  44. export const EXPORT_WINDOWS = [1, 3, 7, 14, 30] as const
  45. /** What you get with no `?days=` at all. A week answers "what has she been doing". */
  46. export const EXPORT_DEFAULT_DAYS = 7
  47. /**
  48. * How many topic headings a day shows before it stops counting them out.
  49. *
  50. * A busy training day can run to twenty topics, and twenty headings run
  51. * together on one line is not a short answer. So the list truncates and says
  52. * so (rule 5, never a silently shortened list).
  53. */
  54. export const EXPORT_BRIEF_TOPICS = 6
  55. /** `days` null means every session on the plan, however old. */
  56. export interface ExportWindow {
  57. /** Null for "everything". */
  58. days: number | null
  59. /** Inclusive ISO bounds. `from` is null when `days` is null. */
  60. from: string | null
  61. to: string
  62. /**
  63. * True when the request asked for something this module could not honour and
  64. * it used the default instead. The page says so; a silently corrected input
  65. * is a choice the reader never made being presented back as theirs.
  66. */
  67. fellBack: boolean
  68. }
  69. /**
  70. * Read `?days=`. `all` means everything; a listed window means itself; anything
  71. * else (a typo, a pasted number, an empty string) means the default and says
  72. * it did.
  73. */
  74. export function parseWindow(raw: string | null | undefined, today: string): ExportWindow {
  75. const v = (raw ?? '').trim().toLowerCase()
  76. if (v === 'all') return { days: null, from: null, to: today, fellBack: false }
  77. if (!v) return window(EXPORT_DEFAULT_DAYS, today, false)
  78. const n = Number(v)
  79. const ok = Number.isInteger(n) && (EXPORT_WINDOWS as readonly number[]).includes(n)
  80. return window(ok ? n : EXPORT_DEFAULT_DAYS, today, !ok)
  81. }
  82. function window(days: number, today: string, fellBack: boolean): ExportWindow {
  83. // days=1 is today alone, so the span is days-1 back from today.
  84. return { days, from: addDays(today, -(days - 1)), to: today, fellBack }
  85. }
  86. /** Bare-date arithmetic. A date a person typed has no time of day to convert. */
  87. export function addDays(day: string, delta: number): string {
  88. const t = Date.parse(`${day}T00:00:00Z`)
  89. if (Number.isNaN(t)) return day
  90. return new Date(t + delta * 86_400_000).toISOString().slice(0, 10)
  91. }
  92. /** Today, on the board's clock. Everything here is Meridian time. */
  93. export const exportToday = (now: Date = new Date()): string => localDay(now.toISOString())
  94. /** What a window turned up, and what it could not include. */
  95. export interface ExportSlice {
  96. sessions: OnboardingDay[]
  97. /**
  98. * Standing groups, `day: null`. They carry no date, so they cannot be in or
  99. * out of a window; they are counted so the page can say what it is not
  100. * showing rather than letting the reader assume they were covered.
  101. */
  102. standingHeld: number
  103. /** The latest session at or before `to` that fell outside the window, if any. */
  104. lastBefore: OnboardingDay | null
  105. /** Sessions on this plan at all, regardless of window. */
  106. totalSessions: number
  107. }
  108. export function sliceFor(plan: OnboardingPlan, win: ExportWindow): ExportSlice {
  109. const dated = plan.days.filter(
  110. (d): d is OnboardingDay & { date: string } => d.day !== null && d.date !== null,
  111. )
  112. const upToNow = dated.filter((d) => d.date <= win.to)
  113. const sessions = upToNow
  114. .filter((d) => (win.from === null ? true : d.date >= win.from))
  115. .sort((a, b) => a.date.localeCompare(b.date))
  116. const outside = upToNow
  117. .filter((d) => (win.from === null ? false : d.date < win.from))
  118. .sort((a, b) => a.date.localeCompare(b.date))
  119. return {
  120. sessions,
  121. standingHeld: plan.days.filter((d) => d.day === null).length,
  122. lastBefore: outside.at(-1) ?? null,
  123. totalSessions: dated.length,
  124. }
  125. }
  126. const spanLabel = (win: ExportWindow): string => {
  127. if (win.days === null) return 'everything so far'
  128. if (win.days === 1) return `${calendarDate(win.to)} only`
  129. return `${calendarDate(win.from ?? win.to)} to ${calendarDate(win.to)}, last ${win.days} days`
  130. }
  131. // --- the recap model -----------------------------------------------------------------
  132. /**
  133. * Built once, rendered twice. Every sentence the reader can see is a string in
  134. * this model, so "the page and the clipboard say the same thing" is a fact a
  135. * test can check rather than an intention two functions share.
  136. */
  137. export interface RecapSessionBlock {
  138. title: string | null
  139. points: string[]
  140. }
  141. /** A day that has been written up, or one that has not. Never a day left out. */
  142. export type RecapBlock =
  143. | {
  144. kind: 'recap'
  145. heading: string
  146. sessions: RecapSessionBlock[]
  147. /** Null renders as "not recorded for this day", not as a missing line. */
  148. accomplished: string | null
  149. }
  150. | { kind: 'none'; heading: string; sentence: string }
  151. export interface RecapModel {
  152. /** Null when there is no `recapDoc`: the plan has no document to head. */
  153. title: string | null
  154. subtitle: string | null
  155. blocks: RecapBlock[]
  156. /** Always present. Says how much of the plan this document covers. */
  157. coverage: string
  158. /** Set when there are no blocks at all, and says which nothing this is. */
  159. empty: string | null
  160. person: string
  161. }
  162. /**
  163. * "Sept 14": the written recap's abbreviation, which is not `dates.ts`'s "Sep".
  164. *
  165. * A document-local table on purpose. `MONTHS` in dates.ts is shared by the
  166. * board's data surfaces, where "Sep" matches what the Ninety UI prints and
  167. * changing it would move text on every page. This is one document's house
  168. * style, and it stays where that document is.
  169. */
  170. const RECAP_MONTHS = [
  171. 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'June',
  172. 'July', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec',
  173. ]
  174. const recapDate = (day: string): string => {
  175. const [, , m, d] = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day) ?? []
  176. return m && d ? `${RECAP_MONTHS[Number(m) - 1]} ${Number(d)}` : day
  177. }
  178. /** "Day 1 (Sept 14): Onboarding", the written recap's heading, unpadded. */
  179. const recapHeading = (d: OnboardingDay, label: string): string =>
  180. `Day ${d.day} (${d.date ? recapDate(d.date) : 'undated'}): ${label}`
  181. /**
  182. * The date range in the subtitle, computed from what is rendered.
  183. *
  184. * Never from the window: `?days=30` on a plan whose training was three days in
  185. * September must not print a month-long span. A document that overstates its
  186. * own coverage in its own subtitle is the prime invariant at its most public.
  187. */
  188. export function recapRange(days: OnboardingDay[]): string | null {
  189. const dates = days.map((d) => d.date).filter((d): d is string => d !== null).sort()
  190. const first = dates[0]
  191. const last = dates.at(-1)
  192. if (!first || !last) return null
  193. if (first === last) return calendarDateLong(first)
  194. const [fy, fm] = first.split('-')
  195. const [ly, lm] = last.split('-')
  196. // Same month: "September 14 to 16, 2026". Same year: "September 28 to
  197. // October 2, 2026". Otherwise both years, because December to January
  198. // without them reads as running backwards.
  199. if (fy === ly && fm === lm) {
  200. return `${calendarDateLong(first).replace(/,.*/, '')} to ${Number(last.slice(8))}, ${fy}`
  201. }
  202. if (fy === ly) {
  203. return `${calendarDateLong(first).replace(/,.*/, '')} to ${calendarDateLong(last)}`
  204. }
  205. return `${calendarDateLong(first)} to ${calendarDateLong(last)}`
  206. }
  207. /**
  208. * The plan's own topics for a day nobody has written up yet.
  209. *
  210. * The one list on this page that is still a truncation, and so the one place
  211. * rule 5 still applies: capped, and saying by how much. It is evidence that the
  212. * day happened, not a recap of it, which is why it appears only inside a
  213. * `kind:'none'` block and never beside authored points.
  214. */
  215. function planTopicsLine(day: OnboardingDay): string {
  216. const all = day.sections.map((s) => s.title)
  217. if (!all.length) return 'The training plan records no topics under it either.'
  218. const shown = all.slice(0, EXPORT_BRIEF_TOPICS)
  219. const rest = all.length - shown.length
  220. return (
  221. `The training plan records ${all.length} topic${all.length === 1 ? '' : 's'} under it: ` +
  222. shown.join(' · ') +
  223. (rest ? ` … and ${rest} more` : '') +
  224. '.'
  225. )
  226. }
  227. /**
  228. * The seven states this page must keep apart, all here so they can be read
  229. * together:
  230. *
  231. * 1 a day in the window, written up → a `recap` block
  232. * 2 a day in the window, not written up → a `none` block, never omitted
  233. * 3 written up, but `accomplished` is null → the line says "not recorded"
  234. * 4 no days in the window, but the plan has some → `empty`, naming the last one
  235. * 5 no days on the plan at all → `empty`, with no window language
  236. * 6 days exist, no recap written anywhere → `empty`, and the blocks
  237. * 7 no plan visible to this viewer → handled by the caller
  238. *
  239. * Collapsing any two of them turns "nobody has written this up" into "nothing
  240. * happened", which is the prime invariant in the form that actually misleads a
  241. * reader: this document is the evidence somebody's first week is going well.
  242. */
  243. export function buildRecap(plan: OnboardingPlan, win: ExportWindow): RecapModel {
  244. const slice = sliceFor(plan, win)
  245. const doc = plan.recapDoc ?? null
  246. const blocks: RecapBlock[] = slice.sessions.map((d) =>
  247. d.recap
  248. ? {
  249. kind: 'recap' as const,
  250. heading: recapHeading(d, d.recap.label),
  251. sessions: d.recap.sessions.map((x) => ({ title: x.title ?? null, points: x.points })),
  252. accomplished: d.recap.accomplished,
  253. }
  254. : {
  255. kind: 'none' as const,
  256. heading: recapHeading(d, d.title),
  257. sentence: `No recap has been written for this day yet. ${planTopicsLine(d)}`,
  258. },
  259. )
  260. const written = blocks.filter((b) => b.kind === 'recap').length
  261. const empty =
  262. slice.sessions.length === 0
  263. ? slice.totalSessions === 0
  264. ? 'No training days are recorded on this plan yet.'
  265. : slice.lastBefore
  266. ? `No training days fall in this window. The most recent was ${recapHeading(
  267. slice.lastBefore,
  268. slice.lastBefore.recap?.label ?? slice.lastBefore.title,
  269. )}.`
  270. : 'No training days fall in this window.'
  271. : written === 0
  272. ? 'No recap has been written for this plan yet. The training days below are the ones it would cover.'
  273. : null
  274. return {
  275. title: doc?.title ?? null,
  276. subtitle: doc ? recapSubtitle(plan, doc, slice.sessions) : null,
  277. blocks,
  278. coverage: recapCoverage(slice, win),
  279. empty,
  280. person: plan.person,
  281. }
  282. }
  283. function recapSubtitle(
  284. plan: OnboardingPlan,
  285. doc: NonNullable<OnboardingPlan['recapDoc']>,
  286. shown: OnboardingDay[],
  287. ): string {
  288. // The role comes from the roster's one copy of it, never a second spelling
  289. // stored beside the document.
  290. const range = recapRange(shown)
  291. return `${plan.role} onboarding${range ? `, ${range}` : ''}. Prepared by ${doc.preparedBy}.`
  292. }
  293. /**
  294. * Always present, not only when it differs from the whole (rule 9).
  295. *
  296. * "3 of 3" is a claim of completeness and is worth making out loud; leaving it
  297. * off when nothing was withheld means the reader has to infer completeness from
  298. * silence, and silence is what a bug looks like too.
  299. */
  300. function recapCoverage(slice: ExportSlice, win: ExportWindow): string {
  301. const n = slice.sessions.length
  302. const total = slice.totalSessions
  303. const scope =
  304. n === total
  305. ? `Covering ${n} of ${total} training day${total === 1 ? '' : 's'} on this plan.`
  306. : `Covering ${n} of ${total} training days on this plan; the window is ${spanLabel(win)}.`
  307. // Said once, not per day. It is a fact about the document, and repeating it
  308. // under every heading is noise in a thing built to be pasted. It matters
  309. // because the points are curated, not capped: there
  310. // is no "… and 13 more" to offer, and implying one would promise a list
  311. // nobody can reach.
  312. const curated = n
  313. ? ' Curated write-ups, not the full training material, which is on the Onboarding tab.'
  314. : ''
  315. const held = slice.standingHeld
  316. ? ` ${standingPhrase(slice.standingHeld)} ongoing rather than tied to a day, and not listed here.`
  317. : ''
  318. return `${scope}${curated}${held}`
  319. }
  320. const standingPhrase = (n: number): string =>
  321. n === 1 ? '1 other section of the plan is' : `${n} other sections of the plan are`
  322. /** Every reader-visible sentence in the model. The parity test walks this. */
  323. export function sentencesOf(model: RecapModel): string[] {
  324. const out: string[] = []
  325. if (model.title) out.push(model.title)
  326. if (model.subtitle) out.push(model.subtitle)
  327. if (model.empty) out.push(model.empty)
  328. for (const b of model.blocks) {
  329. out.push(b.heading)
  330. if (b.kind === 'none') out.push(b.sentence)
  331. else {
  332. for (const s of b.sessions) {
  333. if (s.title) out.push(s.title)
  334. out.push(...s.points)
  335. }
  336. out.push(b.accomplished ?? 'not recorded for this day')
  337. }
  338. }
  339. out.push(model.coverage)
  340. return out
  341. }
  342. // --- the two renderers ---------------------------------------------------------------
  343. /**
  344. * The document. `<article>` and not `<div class="card">`, which matters more
  345. * than it looks: THEME_CSS's print block sets `.card{break-inside:avoid}`, and
  346. * a day with several sessions and a dozen points does not fit one sheet. Asking
  347. * a box taller than the page not to break produces a worse break than letting
  348. * it.
  349. */
  350. export function recapHtml(model: RecapModel): string {
  351. return `<article class="rc-doc">
  352. ${
  353. model.title
  354. ? `<header>
  355. <h1 class="rc-title">${esc(model.title)}</h1>
  356. ${model.subtitle ? `<p class="rc-sub">${esc(model.subtitle)}</p>` : ''}
  357. </header>`
  358. : ''
  359. }
  360. ${model.empty ? `<p class="rc-none">${esc(model.empty)}</p>` : ''}
  361. ${map(model.blocks, (b) =>
  362. b.kind === 'none'
  363. ? `<section class="rc-day rc-day-bare">
  364. <h2 class="rc-day-head">${esc(b.heading)}</h2>
  365. <p class="rc-none">${esc(b.sentence)}</p>
  366. </section>`
  367. : `<section class="rc-day">
  368. <h2 class="rc-day-head">${esc(b.heading)}</h2>
  369. ${map(b.sessions, (x) =>
  370. `${x.title ? `<h3 class="rc-session">${esc(x.title)}</h3>` : ''}
  371. <ul class="rc-points">${map(x.points, (pt) => `<li>${esc(pt)}</li>`)}</ul>`,
  372. )}
  373. ${
  374. b.accomplished
  375. ? `<p class="rc-acc"><b>Accomplished:</b> ${esc(b.accomplished)}</p>`
  376. : `<p class="rc-acc rc-acc-none"><b>Accomplished:</b> not recorded for this day.</p>`
  377. }
  378. </section>`,
  379. )}
  380. <p class="rc-coverage">${esc(model.coverage)}</p>
  381. </article>`
  382. }
  383. /**
  384. * The same document as plain text, for pasting into a chat.
  385. *
  386. * `- ` and not `• `: the destination is Bitrix chat, Slack or mail, where a
  387. * hyphen is a list in every one of them and a literal hyphen in the ones that
  388. * render nothing, whereas a bullet character is only ever a literal. No `**`
  389. * either; Bitrix prints it.
  390. *
  391. * Sub-sessions indent, because plain text has no other way to show that a day
  392. * was several named sittings, and flattening it into one run of siblings throws
  393. * away the thing the headings were written to say.
  394. */
  395. export function recapText(model: RecapModel): string {
  396. const out: string[] = []
  397. if (model.title) out.push(model.title)
  398. if (model.subtitle) out.push(model.subtitle)
  399. if (out.length) out.push('')
  400. if (model.empty) out.push(model.empty, '')
  401. for (const b of model.blocks) {
  402. out.push(b.heading)
  403. if (b.kind === 'none') out.push(b.sentence)
  404. else {
  405. const many = b.sessions.length > 1 || b.sessions.some((x) => x.title)
  406. for (const x of b.sessions) {
  407. if (x.title) out.push(` ${x.title}`)
  408. for (const pt of x.points) out.push(`${many ? ' ' : ''}- ${pt}`)
  409. }
  410. out.push(
  411. b.accomplished
  412. ? `Accomplished: ${b.accomplished}`
  413. : 'Accomplished: not recorded for this day.',
  414. )
  415. }
  416. out.push('')
  417. }
  418. out.push(model.coverage)
  419. return out.join('\n')
  420. }
  421. /** Every visible plan, one document after another. Today there is one. */
  422. export function recapTextAll(plans: OnboardingPlan[], win: ExportWindow): string {
  423. if (!plans.length) return 'No onboarding plans are visible to you.'
  424. return plans.map((p) => recapText(buildRecap(p, win))).join('\n\n---\n\n')
  425. }
  426. /**
  427. * The page: the document, and the controls around it.
  428. *
  429. * Every control carries `.rc-noprint`, because print hides by that marker
  430. * rather than by element name: a rule about `.note` or `.hint` in this sheet
  431. * would be a rule about every page the sheet reaches, and it reaches the inbox.
  432. */
  433. export function recapPage(
  434. plans: OnboardingPlan[],
  435. win: ExportWindow,
  436. current: string,
  437. staleDetail = false,
  438. ): string {
  439. const models = plans.map((p) => buildRecap(p, win))
  440. const text = plans.length
  441. ? recapTextAll(plans, win)
  442. : 'No onboarding plans are visible to you.'
  443. const href = (days: string, format?: 'txt'): string =>
  444. `/onboarding/export?days=${encodeURIComponent(days)}${format ? `&format=${format}` : ''}`
  445. const win$ = (v: string): string =>
  446. `<a class="ob-win${v === current ? ' on' : ''}" href="${esc(href(v))}">${
  447. v === 'all' ? 'All' : `${esc(v)}d`
  448. }</a>`
  449. return `<section class="section rc-noprint">
  450. <div class="section-head"><h2>Training recap</h2>
  451. <span class="hint">print it, or paste it</span></div>
  452. </section>
  453. <!-- NOT class="card". THEME_CSS's print block sets .card{break-inside:avoid},
  454. and this box holds the whole document, which runs to several pages once a
  455. few days are written up. Asking a box taller than the sheet not to break
  456. produces a worse break than letting it. .rc-shell carries the card's LOOK on screen
  457. and nothing on paper. -->
  458. <div class="rc-shell">
  459. <div class="rc-controls rc-noprint">
  460. <div class="ob-wins">
  461. <span class="ob-win-label">Window</span>
  462. ${map([...EXPORT_WINDOWS.map(String), 'all'], win$)}
  463. </div>
  464. <div class="ob-wins" style="margin-top:0">
  465. <span class="ob-win-label">Get it</span>
  466. <span id="rc-print-slot"></span>
  467. <a class="ob-win" href="${esc(href(current, 'txt'))}">Plain text</a>
  468. <span class="rc-hint">&#8984;P, then Save as PDF</span>
  469. </div>
  470. ${
  471. win.fellBack
  472. ? `<p class="note" style="color:var(--caveat)"><b>That window was not one of the
  473. options</b>, so this is the default ${EXPORT_DEFAULT_DAYS} days. Pick one above.</p>`
  474. : ''
  475. }
  476. ${
  477. // An input the page cannot honour is said out loud, not dropped: the
  478. // same rule as `fellBack`. Retired parameters still arrive on old
  479. // links.
  480. staleDetail
  481. ? `<p class="note"><code>detail=</code> no longer does anything. The recap is written
  482. by hand, so there is no list to truncate.</p>`
  483. : ''
  484. }
  485. </div>
  486. ${map(models, (m) => recapHtml(m))}
  487. ${plans.length ? '' : `<p class="rc-none">No onboarding plans are visible to you.</p>`}
  488. <div class="rc-controls rc-noprint" style="border-top:1px solid var(--hairline)">
  489. <textarea id="ob-export" class="ob-export" rows="12" readonly
  490. aria-label="The recap as plain text">${esc(text)}</textarea>
  491. <p class="note" style="margin-top:8px">
  492. <span id="ob-copy-slot"></span>or click the box and select all.
  493. </p>
  494. </div>
  495. </div>
  496. <script>
  497. (function(){
  498. // Progressive only, both of them: with no JS the textarea is still the
  499. // whole feature and Cmd-P is still the whole print story.
  500. try{
  501. var slot=document.getElementById('ob-copy-slot');
  502. var box=document.getElementById('ob-export');
  503. if(navigator.clipboard&&slot&&box){
  504. var b=document.createElement('button');
  505. b.type='button'; b.className='ob-copy'; b.textContent='Copy';
  506. b.addEventListener('click',function(){
  507. navigator.clipboard.writeText(box.value).then(function(){
  508. b.textContent='Copied'; setTimeout(function(){b.textContent='Copy';},1500);
  509. },function(){ b.textContent='Press Cmd-C'; });
  510. });
  511. slot.appendChild(b);
  512. slot.appendChild(document.createTextNode(' · '));
  513. }
  514. var ps=document.getElementById('rc-print-slot');
  515. if(ps&&typeof window.print==='function'){
  516. var p=document.createElement('button');
  517. p.type='button'; p.className='ob-win'; p.textContent='Print / PDF';
  518. p.addEventListener('click',function(){window.print();});
  519. ps.appendChild(p);
  520. }
  521. }catch(e){}
  522. })();
  523. </script>`
  524. }