onboarding-export.ts
Language TypeScript22.3 KB560 lines
/*** "What have you covered with her?": the onboarding material as a few bullets* somebody can paste into a chat.** "How is the new hire's training going?" gets asked most mornings, and the* answer wanted is a few short bullets on what she has been trained on and* what she is able to do. The material is already on the board and already* dated; this is a form of it that survives being pasted into a message.** What is in it, and what is deliberately not: training material written by* the trainer in config/onboarding.ts. Not the trainee's end-of-day notes,* which are her own words, written knowing exactly who reads them. This export* exists to be forwarded to people who are not on that reader list, so* anything in it is effectively theirs to see.** So the rule here is not a filter, it is a source: this module reads* `config/onboarding.ts` and nothing else. It cannot leak a note because it* cannot see one. A test asserts the import graph stays that way, because "we* were careful" is not a control and the next person adding "just the* accomplishments" will not read this comment first.** That is also why it is readable by everyone who can see the tab: anyone who* asks the morning question can run it themselves.** Four things that must not look alike, which is this board's whole discipline* pointed at prose:* - a window with no sessions in it → says so, and names the last one* - a plan with no sessions at all → says that instead* - standing material, which has no date and cannot be in a window → counted* and named as withheld, never silently dropped (rule 9)* - a window the reader did not choose → said out loud, never passed off as* theirs (a bad `?days=` falls back, and the page admits it)** Runtime-neutral (rule 7): the Worker serves this, so no `node:` imports.*/import { calendarDate, calendarDateLong, localDay } from '@meridian/ninety-client'import type { OnboardingDay, OnboardingPlan } from '../../../config/onboarding.js'import { esc, map } from './html.js'/*** The windows offered as buttons. Not a free number box: every value here is* one somebody would actually ask for, and a free box invites `?days=99999`* to mean something different from `all` while looking the same.*/export const EXPORT_WINDOWS = [1, 3, 7, 14, 30] as const/** What you get with no `?days=` at all. A week answers "what has she been doing". */export const EXPORT_DEFAULT_DAYS = 7/*** How many topic headings a day shows before it stops counting them out.** A busy training day can run to twenty topics, and twenty headings run* together on one line is not a short answer. So the list truncates and says* so (rule 5, never a silently shortened list).*/export const EXPORT_BRIEF_TOPICS = 6/** `days` null means every session on the plan, however old. */export interface ExportWindow {/** Null for "everything". */days: number | null/** Inclusive ISO bounds. `from` is null when `days` is null. */from: string | nullto: string/*** True when the request asked for something this module could not honour and* it used the default instead. The page says so; a silently corrected input* is a choice the reader never made being presented back as theirs.*/fellBack: boolean}/*** Read `?days=`. `all` means everything; a listed window means itself; anything* else (a typo, a pasted number, an empty string) means the default and says* it did.*/export function parseWindow(raw: string | null | undefined, today: string): ExportWindow {const v = (raw ?? '').trim().toLowerCase()if (v === 'all') return { days: null, from: null, to: today, fellBack: false }if (!v) return window(EXPORT_DEFAULT_DAYS, today, false)const n = Number(v)const ok = Number.isInteger(n) && (EXPORT_WINDOWS as readonly number[]).includes(n)return window(ok ? n : EXPORT_DEFAULT_DAYS, today, !ok)}function window(days: number, today: string, fellBack: boolean): ExportWindow {// days=1 is today alone, so the span is days-1 back from today.return { days, from: addDays(today, -(days - 1)), to: today, fellBack }}/** Bare-date arithmetic. A date a person typed has no time of day to convert. */export function addDays(day: string, delta: number): string {const t = Date.parse(`${day}T00:00:00Z`)if (Number.isNaN(t)) return dayreturn new Date(t + delta * 86_400_000).toISOString().slice(0, 10)}/** Today, on the board's clock. Everything here is Meridian time. */export const exportToday = (now: Date = new Date()): string => localDay(now.toISOString())/** What a window turned up, and what it could not include. */export interface ExportSlice {sessions: OnboardingDay[]/*** Standing groups, `day: null`. They carry no date, so they cannot be in or* out of a window; they are counted so the page can say what it is not* showing rather than letting the reader assume they were covered.*/standingHeld: number/** The latest session at or before `to` that fell outside the window, if any. */lastBefore: OnboardingDay | null/** Sessions on this plan at all, regardless of window. */totalSessions: number}export function sliceFor(plan: OnboardingPlan, win: ExportWindow): ExportSlice {const dated = plan.days.filter((d): d is OnboardingDay & { date: string } => d.day !== null && d.date !== null,)const upToNow = dated.filter((d) => d.date <= win.to)const sessions = upToNow.filter((d) => (win.from === null ? true : d.date >= win.from)).sort((a, b) => a.date.localeCompare(b.date))const outside = upToNow.filter((d) => (win.from === null ? false : d.date < win.from)).sort((a, b) => a.date.localeCompare(b.date))return {sessions,standingHeld: plan.days.filter((d) => d.day === null).length,lastBefore: outside.at(-1) ?? null,totalSessions: dated.length,}}const spanLabel = (win: ExportWindow): string => {if (win.days === null) return 'everything so far'if (win.days === 1) return `${calendarDate(win.to)} only`return `${calendarDate(win.from ?? win.to)} to ${calendarDate(win.to)}, last ${win.days} days`}// --- the recap model -----------------------------------------------------------------/*** Built once, rendered twice. Every sentence the reader can see is a string in* this model, so "the page and the clipboard say the same thing" is a fact a* test can check rather than an intention two functions share.*/export interface RecapSessionBlock {title: string | nullpoints: string[]}/** A day that has been written up, or one that has not. Never a day left out. */export type RecapBlock =| {kind: 'recap'heading: stringsessions: RecapSessionBlock[]/** Null renders as "not recorded for this day", not as a missing line. */accomplished: string | null}| { kind: 'none'; heading: string; sentence: string }export interface RecapModel {/** Null when there is no `recapDoc`: the plan has no document to head. */title: string | nullsubtitle: string | nullblocks: RecapBlock[]/** Always present. Says how much of the plan this document covers. */coverage: string/** Set when there are no blocks at all, and says which nothing this is. */empty: string | nullperson: string}/*** "Sept 14": the written recap's abbreviation, which is not `dates.ts`'s "Sep".** A document-local table on purpose. `MONTHS` in dates.ts is shared by the* board's data surfaces, where "Sep" matches what the Ninety UI prints and* changing it would move text on every page. This is one document's house* style, and it stays where that document is.*/const RECAP_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'June','July', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec',]const recapDate = (day: string): string => {const [, , m, d] = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day) ?? []return m && d ? `${RECAP_MONTHS[Number(m) - 1]} ${Number(d)}` : day}/** "Day 1 (Sept 14): Onboarding", the written recap's heading, unpadded. */const recapHeading = (d: OnboardingDay, label: string): string =>`Day ${d.day} (${d.date ? recapDate(d.date) : 'undated'}): ${label}`/*** The date range in the subtitle, computed from what is rendered.** Never from the window: `?days=30` on a plan whose training was three days in* September must not print a month-long span. A document that overstates its* own coverage in its own subtitle is the prime invariant at its most public.*/export function recapRange(days: OnboardingDay[]): string | null {const dates = days.map((d) => d.date).filter((d): d is string => d !== null).sort()const first = dates[0]const last = dates.at(-1)if (!first || !last) return nullif (first === last) return calendarDateLong(first)const [fy, fm] = first.split('-')const [ly, lm] = last.split('-')// Same month: "September 14 to 16, 2026". Same year: "September 28 to// October 2, 2026". Otherwise both years, because December to January// without them reads as running backwards.if (fy === ly && fm === lm) {return `${calendarDateLong(first).replace(/,.*/, '')} to ${Number(last.slice(8))}, ${fy}`}if (fy === ly) {return `${calendarDateLong(first).replace(/,.*/, '')} to ${calendarDateLong(last)}`}return `${calendarDateLong(first)} to ${calendarDateLong(last)}`}/*** The plan's own topics for a day nobody has written up yet.** The one list on this page that is still a truncation, and so the one place* rule 5 still applies: capped, and saying by how much. It is evidence that the* day happened, not a recap of it, which is why it appears only inside a* `kind:'none'` block and never beside authored points.*/function planTopicsLine(day: OnboardingDay): string {const all = day.sections.map((s) => s.title)if (!all.length) return 'The training plan records no topics under it either.'const shown = all.slice(0, EXPORT_BRIEF_TOPICS)const rest = all.length - shown.lengthreturn (`The training plan records ${all.length} topic${all.length === 1 ? '' : 's'} under it: ` +shown.join(' · ') +(rest ? ` … and ${rest} more` : '') +'.')}/*** The seven states this page must keep apart, all here so they can be read* together:** 1 a day in the window, written up → a `recap` block* 2 a day in the window, not written up → a `none` block, never omitted* 3 written up, but `accomplished` is null → the line says "not recorded"* 4 no days in the window, but the plan has some → `empty`, naming the last one* 5 no days on the plan at all → `empty`, with no window language* 6 days exist, no recap written anywhere → `empty`, and the blocks* 7 no plan visible to this viewer → handled by the caller** Collapsing any two of them turns "nobody has written this up" into "nothing* happened", which is the prime invariant in the form that actually misleads a* reader: this document is the evidence somebody's first week is going well.*/export function buildRecap(plan: OnboardingPlan, win: ExportWindow): RecapModel {const slice = sliceFor(plan, win)const doc = plan.recapDoc ?? nullconst blocks: RecapBlock[] = slice.sessions.map((d) =>d.recap? {kind: 'recap' as const,heading: recapHeading(d, d.recap.label),sessions: d.recap.sessions.map((x) => ({ title: x.title ?? null, points: x.points })),accomplished: d.recap.accomplished,}: {kind: 'none' as const,heading: recapHeading(d, d.title),sentence: `No recap has been written for this day yet. ${planTopicsLine(d)}`,},)const written = blocks.filter((b) => b.kind === 'recap').lengthconst empty =slice.sessions.length === 0? slice.totalSessions === 0? 'No training days are recorded on this plan yet.': slice.lastBefore? `No training days fall in this window. The most recent was ${recapHeading(slice.lastBefore,slice.lastBefore.recap?.label ?? slice.lastBefore.title,)}.`: 'No training days fall in this window.': written === 0? 'No recap has been written for this plan yet. The training days below are the ones it would cover.': nullreturn {title: doc?.title ?? null,subtitle: doc ? recapSubtitle(plan, doc, slice.sessions) : null,blocks,coverage: recapCoverage(slice, win),empty,person: plan.person,}}function recapSubtitle(plan: OnboardingPlan,doc: NonNullable<OnboardingPlan['recapDoc']>,shown: OnboardingDay[],): string {// The role comes from the roster's one copy of it, never a second spelling// stored beside the document.const range = recapRange(shown)return `${plan.role} onboarding${range ? `, ${range}` : ''}. Prepared by ${doc.preparedBy}.`}/*** Always present, not only when it differs from the whole (rule 9).** "3 of 3" is a claim of completeness and is worth making out loud; leaving it* off when nothing was withheld means the reader has to infer completeness from* silence, and silence is what a bug looks like too.*/function recapCoverage(slice: ExportSlice, win: ExportWindow): string {const n = slice.sessions.lengthconst total = slice.totalSessionsconst scope =n === total? `Covering ${n} of ${total} training day${total === 1 ? '' : 's'} on this plan.`: `Covering ${n} of ${total} training days on this plan; the window is ${spanLabel(win)}.`// Said once, not per day. It is a fact about the document, and repeating it// under every heading is noise in a thing built to be pasted. It matters// because the points are curated, not capped: there// is no "… and 13 more" to offer, and implying one would promise a list// nobody can reach.const curated = n? ' Curated write-ups, not the full training material, which is on the Onboarding tab.': ''const held = slice.standingHeld? ` ${standingPhrase(slice.standingHeld)} ongoing rather than tied to a day, and not listed here.`: ''return `${scope}${curated}${held}`}const standingPhrase = (n: number): string =>n === 1 ? '1 other section of the plan is' : `${n} other sections of the plan are`/** Every reader-visible sentence in the model. The parity test walks this. */export function sentencesOf(model: RecapModel): string[] {const out: string[] = []if (model.title) out.push(model.title)if (model.subtitle) out.push(model.subtitle)if (model.empty) out.push(model.empty)for (const b of model.blocks) {out.push(b.heading)if (b.kind === 'none') out.push(b.sentence)else {for (const s of b.sessions) {if (s.title) out.push(s.title)out.push(...s.points)}out.push(b.accomplished ?? 'not recorded for this day')}}out.push(model.coverage)return out}// --- the two renderers ---------------------------------------------------------------/*** The document. `<article>` and not `<div class="card">`, which matters more* than it looks: THEME_CSS's print block sets `.card{break-inside:avoid}`, and* a day with several sessions and a dozen points does not fit one sheet. Asking* a box taller than the page not to break produces a worse break than letting* it.*/export function recapHtml(model: RecapModel): string {return `<article class="rc-doc">${model.title? `<header><h1 class="rc-title">${esc(model.title)}</h1>${model.subtitle ? `<p class="rc-sub">${esc(model.subtitle)}</p>` : ''}</header>`: ''}${model.empty ? `<p class="rc-none">${esc(model.empty)}</p>` : ''}${map(model.blocks, (b) =>b.kind === 'none'? `<section class="rc-day rc-day-bare"><h2 class="rc-day-head">${esc(b.heading)}</h2><p class="rc-none">${esc(b.sentence)}</p></section>`: `<section class="rc-day"><h2 class="rc-day-head">${esc(b.heading)}</h2>${map(b.sessions, (x) =>`${x.title ? `<h3 class="rc-session">${esc(x.title)}</h3>` : ''}<ul class="rc-points">${map(x.points, (pt) => `<li>${esc(pt)}</li>`)}</ul>`,)}${b.accomplished? `<p class="rc-acc"><b>Accomplished:</b> ${esc(b.accomplished)}</p>`: `<p class="rc-acc rc-acc-none"><b>Accomplished:</b> not recorded for this day.</p>`}</section>`,)}<p class="rc-coverage">${esc(model.coverage)}</p></article>`}/*** The same document as plain text, for pasting into a chat.** `- ` and not `• `: the destination is Bitrix chat, Slack or mail, where a* hyphen is a list in every one of them and a literal hyphen in the ones that* render nothing, whereas a bullet character is only ever a literal. No `**`* either; Bitrix prints it.** Sub-sessions indent, because plain text has no other way to show that a day* was several named sittings, and flattening it into one run of siblings throws* away the thing the headings were written to say.*/export function recapText(model: RecapModel): string {const out: string[] = []if (model.title) out.push(model.title)if (model.subtitle) out.push(model.subtitle)if (out.length) out.push('')if (model.empty) out.push(model.empty, '')for (const b of model.blocks) {out.push(b.heading)if (b.kind === 'none') out.push(b.sentence)else {const many = b.sessions.length > 1 || b.sessions.some((x) => x.title)for (const x of b.sessions) {if (x.title) out.push(` ${x.title}`)for (const pt of x.points) out.push(`${many ? ' ' : ''}- ${pt}`)}out.push(b.accomplished? `Accomplished: ${b.accomplished}`: 'Accomplished: not recorded for this day.',)}out.push('')}out.push(model.coverage)return out.join('\n')}/** Every visible plan, one document after another. Today there is one. */export function recapTextAll(plans: OnboardingPlan[], win: ExportWindow): string {if (!plans.length) return 'No onboarding plans are visible to you.'return plans.map((p) => recapText(buildRecap(p, win))).join('\n\n---\n\n')}/*** The page: the document, and the controls around it.** Every control carries `.rc-noprint`, because print hides by that marker* rather than by element name: a rule about `.note` or `.hint` in this sheet* would be a rule about every page the sheet reaches, and it reaches the inbox.*/export function recapPage(plans: OnboardingPlan[],win: ExportWindow,current: string,staleDetail = false,): string {const models = plans.map((p) => buildRecap(p, win))const text = plans.length? recapTextAll(plans, win): 'No onboarding plans are visible to you.'const href = (days: string, format?: 'txt'): string =>`/onboarding/export?days=${encodeURIComponent(days)}${format ? `&format=${format}` : ''}`const win$ = (v: string): string =>`<a class="ob-win${v === current ? ' on' : ''}" href="${esc(href(v))}">${v === 'all' ? 'All' : `${esc(v)}d`}</a>`return `<section class="section rc-noprint"><div class="section-head"><h2>Training recap</h2><span class="hint">print it, or paste it</span></div></section><!-- NOT class="card". THEME_CSS's print block sets .card{break-inside:avoid},and this box holds the whole document, which runs to several pages once afew days are written up. Asking a box taller than the sheet not to breakproduces a worse break than letting it. .rc-shell carries the card's LOOK on screenand nothing on paper. --><div class="rc-shell"><div class="rc-controls rc-noprint"><div class="ob-wins"><span class="ob-win-label">Window</span>${map([...EXPORT_WINDOWS.map(String), 'all'], win$)}</div><div class="ob-wins" style="margin-top:0"><span class="ob-win-label">Get it</span><span id="rc-print-slot"></span><a class="ob-win" href="${esc(href(current, 'txt'))}">Plain text</a><span class="rc-hint">⌘P, then Save as PDF</span></div>${win.fellBack? `<p class="note" style="color:var(--caveat)"><b>That window was not one of theoptions</b>, so this is the default ${EXPORT_DEFAULT_DAYS} days. Pick one above.</p>`: ''}${// An input the page cannot honour is said out loud, not dropped: the// same rule as `fellBack`. Retired parameters still arrive on old// links.staleDetail? `<p class="note"><code>detail=</code> no longer does anything. The recap is writtenby hand, so there is no list to truncate.</p>`: ''}</div>${map(models, (m) => recapHtml(m))}${plans.length ? '' : `<p class="rc-none">No onboarding plans are visible to you.</p>`}<div class="rc-controls rc-noprint" style="border-top:1px solid var(--hairline)"><textarea id="ob-export" class="ob-export" rows="12" readonlyaria-label="The recap as plain text">${esc(text)}</textarea><p class="note" style="margin-top:8px"><span id="ob-copy-slot"></span>or click the box and select all.</p></div></div><script>(function(){// Progressive only, both of them: with no JS the textarea is still the// whole feature and Cmd-P is still the whole print story.try{var slot=document.getElementById('ob-copy-slot');var box=document.getElementById('ob-export');if(navigator.clipboard&&slot&&box){var b=document.createElement('button');b.type='button'; b.className='ob-copy'; b.textContent='Copy';b.addEventListener('click',function(){navigator.clipboard.writeText(box.value).then(function(){b.textContent='Copied'; setTimeout(function(){b.textContent='Copy';},1500);},function(){ b.textContent='Press Cmd-C'; });});slot.appendChild(b);slot.appendChild(document.createTextNode(' · '));}var ps=document.getElementById('rc-print-slot');if(ps&&typeof window.print==='function'){var p=document.createElement('button');p.type='button'; p.className='ob-win'; p.textContent='Print / PDF';p.addEventListener('click',function(){window.print();});ps.appendChild(p);}}catch(e){}})();</script>`}