onboarding.ts
Language TypeScript38.7 KB964 lines
/*** Onboarding plans: the training material a new person needs in their first* weeks, and the switch that decides whether they still see it.** A config file and not board state. An editable page backed by KV would be* more board-owned state (rule 12) carrying the one kind of content that* genuinely wants review: what a new hire is told to do. Here it is versioned,* it diffs, and it deploys with everything else. The cost is that adding* material is an edit rather than a paste.** The toggle is `active`, and it is the whole feature: the tab is on while a* person is new to the team. Set it false and the tab disappears for everyone, the plan stays in the file, and* turning it back on is one word. Nothing is deleted to hide it, because a* deleted plan is a deleted record of what somebody was actually taught.** What else the toggle takes with it: the end-of-day note lives on this tab,* so switching a plan off removes that person's form and the readers' log of everything she has written. The rows stay in D1 and come* back when the plan does, but somebody flipping this to mean "she is not new* any more" would also, silently, stop the daily read. To stop asking without* hiding the history, remove her line from `CHECKIN_AUTHORS` in* config/checkin.ts and leave the plan on.** Who sees it: the person the plan is about, plus anyone who reads the* Leadership Team's to-dos, so that the people responsible for a new hire can* see what she is being shown without flagging themselves as new. Everyone* else gets no tab at all, not an empty one: a tab that opens on somebody* else's training is a tab about nothing.** `person` is checked against the roster at load. A typo would produce a plan* nobody can see and no error anywhere (rule 13's failure mode exactly, a* thing that silently does nothing), so it throws instead.** The shape is days of topics: training split by the day it was covered, each* day with topic headers and what was covered under them, both levels* collapsible, and one master resources list at the bottom. That answers the* question a trainee actually asks ("what did we do on Tuesday?") and lets a* reader skip a day they have already worked through.** Not every group is a day, and that is deliberate. A `day` of null marks* standing material: the rhythm she runs every morning, the threads that are* open right now, what is still owed her, where things live. Filing those* under the date they happened to be mentioned would date-stamp a thing that is* true every day, and bury today's work inside a session she has already read.** Links live in one place. `resources` at the foot of the plan is the master* list, and a day's `recordings` must also appear in it, checked at load* below. A "master" list that is missing the thing you are looking for is the* rule-9 failure in its quietest form: collected, attributed, and then not* shown.** Runtime-neutral: no `node:` imports, because the Worker reads this (rule 7).*/import { MERIDIAN_PEOPLE } from './meridian-people.js'import type { Viewer } from '../apps/dashboard/src/viewer.js'/** A link worth bookmarking. `note` is why it matters, not what it is. */export interface OnboardingLink {label: stringhref: stringnote?: string}/** A labelled row: a task, a place, a person to ask. */export interface OnboardingRow {label: stringdetail: string/** A short marker: DAILY, BLOCKING, TODAY. Rendered as a chip. */tag?: string}/*** A topic: one heading, and what was covered under it. The collapsible unit* inside a day.** No `links` field. Every link on this plan belongs to the master resources* list, so that there is exactly one place to look (see the file* header). A day's recordings are the one exception, and they are carried on* the day rather than on a topic.*/export interface OnboardingSection {title: stringintro?: stringbullets?: string[]rows?: OnboardingRow[]}/*** The recap: the same days, written for somebody who was not in the room.** A second body of text and not a view of the first, because `sections` above* is reference material addressed to the trainee: "Everything goes in inches"* means something to her and nothing to a reader being told how her week went.* The recap is the outward voice: full sentences, curated rather than* complete, a handful of points where the plan has many more.** A recap derived from `sections` reads as a list of topic titles, which is* not what a reader wants; the seed below is written by hand. Nothing here is* generated from `sections`, and the page never claims it is: the two counts* differ on purpose and the surface says so.** `accomplished` is the trainer's sentence, not the trainee's. It reads like the* end-of-day note's `did` box and it is not: it is his summary of a session he* ran, in a committed file that diffs and deploys, shown to the same people who* can already open the plan. Her notes live in D1 behind `CHECKIN_READERS`,* which is a shorter list than this page's audience (see config/checkin.ts).* The module that renders this imports this file and nothing else, and a test* asserts that import graph, precisely because the field's name is what will* tempt somebody to wire it to the note store.*/export interface OnboardingRecapSession {/** The sub-heading, e.g. "Leads triage". Absent on a single-session day. */title?: string/** The curated points. Never empty; checked at load. */points: string[]}export interface OnboardingRecapDay {/*** The recap's own name for the day: "Onboarding", "Plinth Quotes",* "Two Sessions".** Deliberately not `OnboardingDay.title`. That one names the material to the* person who sat through it; this one names the day to somebody who did not.*/label: string/*** One shape for both kinds of day. A day with several named sessions carries* one entry per session; a day with one carries one, untitled. A separate* `points` field for the simple case would mean two render paths and two* empty states for no gain.*/sessions: OnboardingRecapSession[]/*** What she came out of the day able to do. Null, not optional: writing a* recap should force the question, and an absent field would let "nobody* decided" render identically to "there was nothing".*/accomplished: string | null}/** The document's own heading. Absent until somebody writes a recap. */export interface OnboardingRecapDoc {/*** Verbatim, e.g. "Rosa Onboarding: Training Recap".** Authored rather than derived because deriving it needs a first name, and* the roster holds full names with compound surnames in them.*/title: string/** Who wrote the recap. Usually `trainedBy`, but not necessarily. */preparedBy: string}/*** A day of training, or a standing group that is not a day.** `day` null means standing material (see the file header). `date` is the* session date and must be present exactly when `day` is, checked at load: a* numbered day with no date renders a heading that cannot be placed in time,* and a date on standing material claims it stopped being true afterwards.*/export interface OnboardingDay {/** 1, 2, 3 … for a training session. Null for standing material. */day: number | null/** ISO date of the session, or null on standing material. */date: string | nulltitle: stringintro?: string/*** Whether this group starts expanded.** Set by hand, not derived. The obvious derivation (open the newest day)* has to guess for the standing groups, which are the ones she opens every* morning and which have no date to be newest by. Adding a new day means* opening it here and closing the previous one; both failure modes are a* click.*/open?: boolean/** The recording(s) this day was taken from. Must also be in `resources`. */recordings?: OnboardingLink[]sections: OnboardingSection[]/*** The outward write-up of this day. Absent until somebody writes it, and an* absent recap on a real training day is a state the document renders rather* than skips, because a day silently missing from a recap is a day the reader* is told did not happen.*/recap?: OnboardingRecapDay}/** One heading in the master resources list. */export interface OnboardingResourceGroup {title: stringlinks: OnboardingLink[]}export interface OnboardingPlan {/** Roster display name, exactly. Checked at load; see the file header. */person: stringrole: string/** ISO date of day one. Rendered as "day N" so nobody has to count. */startedOn: stringtrainedBy?: string/** The toggle. False hides the tab and keeps the material. */active: boolean/** One line, in the trainer's voice, at the top of the tab. */summary: string/** Training days and standing groups, in the order they are rendered. */days: OnboardingDay[]/** The master list, at the foot of the plan. Every link on the plan is here. */resources: OnboardingResourceGroup[]/** Where this came from, named so the reader can go to the original. */sources: string[]/*** The recap document's heading. Present once any day has a `recap`; checked* at load. The subtitle around it is assembled, not stored: the role comes* from `role` above and the date range from the days actually rendered, so a* one-day window can never print a three-day span.*/recapDoc?: OnboardingRecapDoc}/*** The plan is the trainer's material, not something generated to fill a tab.** It is seeded from the trainer's own documents (a field guide and a short* slide deck) and the recordings of the training sessions, and the wording is* kept close to his because the phrasing is the part she will recognise from* the session. Where the sessions stop, this stops, and the empty state says* so.** The day split is the recordings' own. Material from the documents sits with* the day whose session covered it; where a document covered something no* session did, it is standing material rather than being assigned to a day it* was not taught on.** Transcripts mishear product names. The meeting recorder writes "Bitrix"* several different ways; this file spells every system one way, so a search* for it finds everything.** The clients named in the worked examples are invented for this plan.*/const ROSA: OnboardingPlan = {person: 'Rosa Galván',role: 'Client Success',startedOn: '2026-09-14',trainedBy: 'Sam Whitaker',active: true,summary:'Four sessions so far: the walkthrough, quoting in Plinth, working the leads pipeline, then ' +'changing an order after it is placed. What you run every day is at the top; the days are ' +'underneath it, and every link is in the master list at the bottom. The SOPs are the source ' +'of truth: where this and an SOP disagree, the SOP wins and Sam wants to know about it.',days: [// ---------------------------------------------------------------------// Standing. Not a session. Open, because this is the group she opens// every morning: the tasks and action items run daily as part of// operations.// ---------------------------------------------------------------------{day: null,date: null,title: 'Every day: the operating rhythm',open: true,intro:'What the job is when nobody is training you. Chats and calls interrupt all of it, and ' +'that is correct: client first, always. Come back to the list afterwards.',sections: [{title: 'Your morning, in order',rows: [{label: '1 · accounts@ inbox',detail:'Work it down. Quote requests, clients needing help, Bitrix / Plinth / Ninety ' +'notifications, sample requests.',tag: 'FIRST',},{label: '2 · Check on your day',detail: 'This board, My day tab: your own briefing and your oldest items.',},{label: '3 · Log yesterday’s numbers',detail:'The KPI sheet: leads assigned, quotes sent, leads converted. In the morning, ' +'so the numbers are ready for the weekly meeting.',},{label: '4 · Work First Reply',detail: 'Then the rest of Bitrix. This is the day’s real job.',},],},{title: 'Every day, without being asked',rows: [{label: 'Work First Reply',detail:'Clear leads out of that column. Nothing else empties it, and a full column ' +'means the queue is not being worked.',tag: 'DAILY',},{/*** The only row on this plan that did not come out of the* trainer's material. It is here* because the plan is where she reads what is expected of her* daily, and a daily expectation that appears only as a form on* the same page is a thing she is being asked for in one place* and told about in none. The card above says the rest.*/label: 'Write the end-of-day note',detail:'Three lines at the top of this tab before you log off: what you did, what you ' +'are unsure about, what you need. The card names exactly who reads it. The middle ' +'box is the point: a question written down tonight gets answered tomorrow.',tag: 'DAILY',},{label: 'Go through Ask Sam',detail:'End of the day, with Sam. Everything you parked there gets a decision. A full ' +'column is fine; it shows where the flow is ambiguous.',tag: 'DAILY',},],},{title: 'Once a week',rows: [{label: 'Clear Reorder in Progress',detail:'That column collects live conversations so they do not get junked. Empty it ' +'weekly.',tag: 'WEEKLY',},],},],},// ---------------------------------------------------------------------// Standing. Live threads. Open, because a thread waiting on somebody is// only useful while you can still see it without a click.// ---------------------------------------------------------------------{day: null,date: null,title: 'Open right now: yours to chase',open: true,intro:'Threads that left a session waiting on somebody else. Nobody else is watching for these ' +'replies.',sections: [{title: 'Waiting on a client or a supplier',rows: [{label: 'Acme Pet Supply',detail:'Waiting on the final height for two vinyl banners. Asked 15 September; no reply ' +'recorded since.',tag: 'WAITING',},{/*** A row on a live thread says what it is waiting on now. When the* supplier answers, the row changes to the next thing; a stale* row is worse than no row, because she would chase the supplier* who has already replied.*/label: 'Globex Conference Group',detail:'The supplier confirmed the custom tower can be built, with a six-week lead ' +'time. Sam emailed the client to confirm the timing and CC’d you; waiting on ' +'their answer before asking for a final quote.',tag: 'WAITING',},],},{title: 'Ask Sam',intro:'The parking bucket. It is a real column on the leads Kanban with no automation on ' +'it: nothing moves in or out unless somebody moves it.',bullets: ['Anything you are unsure where to put goes there, however many.','You go through it together at the end of the day.',],},],},// ---------------------------------------------------------------------// Day 01// ---------------------------------------------------------------------{day: 1,date: '2026-09-14',title: 'The role, the tools, and the pipeline',intro:'The walkthrough: what the job is, where everything lives, and how a lead moves from ' +'arriving to converted.',recordings: [{label: 'Day 01: Onboarding',href: 'https://fathom.video/share/EXAMPLE-day-01',},],recap: {label: 'Onboarding',sessions: [{points: ['Role overview and priorities: client first, and the accounts@ inbox every morning','Daily KPI logging (leads assigned, quotes sent, leads converted)','Bitrix leads pipeline walkthrough: which stages move automatically and which are moved by hand','How to convert a lead correctly: confirm the order in Plinth first, then complete the lead','Tour of this board and of Claude with the Bitrix and Plinth connectors',],},],accomplished: 'full pipeline orientation, tools access set up, started on the First Reply column',},sections: [{title: 'Client first, always',intro: 'The one principle everything else is arranged around.',bullets: ['A client waiting beats anything else on your list. Chats and calls interrupt the queue and that is correct.','In-hand date = the deadline: the last day the client can receive it. It is the number that matters on every order.',],},{title: 'The Bitrix leads pipeline',intro: 'The CRM for everything with revenue attached. Kanban, and the columns mean things.',bullets: ['Bitrix turns an incoming email into a lead whenever it does not recognise the sender, so nearly everything that lands in accounts@ appears on the leads board.','Only leads and reorders belong there. Marketing, notifications, a verification code you triggered yourself: all Junk.','Some stages move by themselves (a quote sent from Plinth moves the lead to Quote Sent). Anything quoted by email or chat, you move yourself.',],},{title: 'Converting a won lead',intro: 'Check first, then close it out.',bullets: ['Check Plinth first and confirm the order actually exists.','Complete the lead, pick the outcome (Converted Lead or Reorder Completed), fill the qualifier fields and save.','Converted or reorder? Open the client and look for previous orders. None means a new client.',],},{title: 'Two shipments, two tracking numbers',intro: '“I got a tracking number but only the hardware showed up.” A question you will hear often.',bullets: ['Hardware and printed graphics often ship separately, on different days, with their own tracking numbers. That is normal.','What to say: it is on the way, and the hardware tracking goes out first so everything arrives before the in-hand date.',],},{title: 'Claude, with Bitrix and Plinth connected',intro: 'Guardrails first.',bullets: ['Plinth is read-only from Claude. It cannot change an order, and that is a feature.','Bitrix can write. Confirm before it changes a stage or a field on a real lead.','It is AI. Verify anything you are about to put in front of a client.',],},],},// ---------------------------------------------------------------------// Day 02// ---------------------------------------------------------------------{day: 2,date: '2026-09-15',title: 'Quoting in Plinth, end to end',intro:'One quote written start to finish, then one that could not be quoted as asked. The ' +'worked example is what makes the next one obvious.',recordings: [{label: 'Day 02: Quoting in Plinth',href: 'https://fathom.video/share/EXAMPLE-day-02',},],recap: {label: 'Plinth Quotes',sessions: [{points: ['Built a quote start to finish in Plinth: product, dimensions, finishing, price, shipping','What to do with an incomplete or unworkable request: ask the client, or propose an alternative','Worked a live example: a banner request with a dimension that did not add up','When a custom product goes to a supplier for feasibility instead of being quoted directly',],},],accomplished: 'sent her first quotes on her own and messaged clients directly in Bitrix',},sections: [{title: 'Writing a quote in Plinth',rows: [{label: 'Everything goes in inches',detail:'An 8 × 12 ft banner is 96 by 144. Multiply by twelve before it goes in the ' +'box, every time.',tag: 'EASY TO MISS',},{label: 'Custom, or predefined?',detail:'A predefined product carries an SKU and the options already chosen. A custom ' +'request is free text: copy it somewhere first, then add the real product ' +'properly.',},{label: 'Then check it actually went',detail: 'The email log in Plinth shows whether the quote email left.',},],},{title: 'When you cannot quote it yet',rows: [{label: 'A number that looks wrong',detail: 'Ask the client before you price it. A quick question beats a remake.',},{label: 'Something we cannot make',detail: 'Say so, and offer the closest thing we can make instead.',},],},{title: 'Worked example: the Acme Pet Supply banners',bullets: ['The request gave a height that could not match the width they described.','Rather than guess, the quote waited on one short question to the client.','The thread is yours now; it is on the list at the top.',],},],},// ---------------------------------------------------------------------// Day 03. Two sessions, so two recordings.// ---------------------------------------------------------------------{day: 3,date: '2026-09-16',title: 'Working the pipeline, and a custom quote by email',intro:'A triage pass through the leads board, then a custom request quoted by email with a ' +'supplier in the loop.',recordings: [{label: 'Day 03a: Leads triage',href: 'https://fathom.video/share/EXAMPLE-day-03a',},{label: 'Day 03b: Custom quote by email',href: 'https://fathom.video/share/EXAMPLE-day-03b',},],recap: {label: 'Two Sessions',sessions: [{title: 'Leads triage',points: ['Worked the First Reply column lead by lead, deciding where each one belonged','Converted lead or reorder completed: how to tell from the client’s order history','A lead counts as converted when the order is paid, not when it ships',],},{title: 'Custom quote by email',points: ['Used the client’s budget to decide the spec before asking for a price','Confirmed the lead time with the supplier before requesting a quote',],},],accomplished: 'triaged the First Reply column with Sam and drafted a custom quote by email',},sections: [{title: 'First Reply means your first reply',bullets: ['A lead sits in First Reply until a person has actually answered the client.','An auto-reply does not count, even though it can move the lead there.',],},{title: 'A lead converts when they pay, not when it ships',bullets: ['An order that is paid and in production is already a converted lead.','Then the only question left is new client or returning client.',],},{title: 'Client tiers',bullets: ['Clients are Retail, Palladium Partner, Platinum Partner, Iridium Partner or Rhodium Partner. The tier goes in the qualifier field when you convert.','Clients get an email when they move up a tier. That outreach is not yours yet.',],},{title: 'Worked example: the Globex tower',bullets: ['Globex Conference Group asked for a custom display tower that is not on the site.','Their budget decided the size before anybody asked for a price.','The supplier confirmed it could be built and gave a lead time first; the quote comes after the client accepts the timing.',],},],},// ---------------------------------------------------------------------// Day 04. Open, because it is the newest day.// ---------------------------------------------------------------------{day: 4,date: '2026-09-17',title: 'Changing an order after it is placed',open: true,intro: 'The order is paid and the client wants something different. How to change it cleanly.',recordings: [{label: 'Day 04: Order changes',href: 'https://fathom.video/share/EXAMPLE-day-04',},],recap: {label: 'Order Changes',sessions: [{points: ['Modifying a placed order: record the original total, swap the product, recheck shipping','Charging or refunding the difference, and marking the order so production knows','Checking tracking before sending it to a client',],},],// Not written up yet. Null, not a blank: the recap says "not recorded".accomplished: null,},sections: [{title: 'Modifying an order',rows: [{label: 'Screenshot the total first',detail: 'Before you touch anything, so the difference can be worked out afterwards.',tag: 'FIRST',},{label: 'Swap, do not edit',detail: 'Remove the old product line and add the new one.',},{label: 'Shipping does not recalculate itself',detail: 'Check it by hand after the swap.',},{label: 'Charge the difference',detail: 'Send a payment request for anything owed, or refund anything overpaid.',},],},{title: 'Check the tracking before you send it',bullets: ['Open the tracking link yourself first. A number that does not resolve yet is a confused client tomorrow.',],},],},// ---------------------------------------------------------------------// Standing. What is still owed, and where things live.// ---------------------------------------------------------------------{day: null,date: null,title: 'Waiting on Sam',sections: [{title: 'Still owed',rows: [{label: 'Reorder in Progress',detail: 'A walkthrough of how that column is worked week to week.',},{label: 'The Plinth status flowchart',detail: 'One page showing how an order moves from placed to delivered.',},],},],},{day: null,date: null,title: 'Reference',sections: [{title: 'Who to ask',rows: [{ label: 'Sam', detail: 'Anything client success. Default to him first.' },{ label: 'Theo', detail: 'Office and operations: claims, shipping problems, product feasibility.' },{ label: 'Marco', detail: 'Designer.' },{ label: 'Rafael', detail: 'Designer, and the production owner on your orders.' },{ label: 'Nora', detail: 'General manager. Runs the weekly meeting and Ninety.' },],},{title: 'Where things live',rows: [{ label: 'Leads, contacts', detail: 'Bitrix24 → CRM → Leads → Kanban' },{ label: 'Quotes', detail: 'Plinth → Quote Management' },{ label: 'Orders', detail: 'Plinth → Orders' },{ label: 'Did my email send?', detail: 'Plinth → Email Log' },{ label: 'Your day, every inbox', detail: 'This board' },],},],},],resources: [{title: 'The SOPs: source of truth',links: [{label: 'SOP index',href: 'https://docs.google.com/spreadsheets/d/EXAMPLE',note: 'Every SOP we have, in one sheet.',},{label: 'Client Success Position SOP',href: 'https://docs.google.com/document/d/EXAMPLE',},{label: 'How to Manage Leads in Bitrix24',href: 'https://docs.google.com/document/d/EXAMPLE',},],},{title: 'Plinth',links: [{ label: 'Orders', href: 'https://meridiandisplay.example/admin/orders' },{ label: 'Quote Management', href: 'https://meridiandisplay.example/admin/quotes' },],},{title: 'The spreadsheets',links: [{label: 'KPI sheet',href: 'https://docs.google.com/spreadsheets/d/EXAMPLE',note: 'Your daily numbers go here.',},],},{title: 'The training recordings',links: [{ label: 'Day 01: Onboarding', href: 'https://fathom.video/share/EXAMPLE-day-01' },{ label: 'Day 02: Quoting in Plinth', href: 'https://fathom.video/share/EXAMPLE-day-02' },{ label: 'Day 03a: Leads triage', href: 'https://fathom.video/share/EXAMPLE-day-03a' },{label: 'Day 03b: Custom quote by email',href: 'https://fathom.video/share/EXAMPLE-day-03b',},{ label: 'Day 04: Order changes', href: 'https://fathom.video/share/EXAMPLE-day-04' },],},],recapDoc: {title: 'Rosa Onboarding: Training Recap',preparedBy: 'Sam Whitaker',},sources: ['Training day 04, 2026-09-17 (Fathom recording)','Training day 03, 2026-09-16, two sessions (Fathom recordings)','Training day 02, 2026-09-15 (Fathom recording)','Training day 01, 2026-09-14 (Fathom recording)','Training Recap, 2026-09-17 (Sam’s write-up; the recap text is his)','Client Success Field Guide (2026-09-14)','Client Success Onboarding slides (2026-09-14)','SOP: Client Success Position','SOP: How to Manage Leads in Bitrix24',],}export const ONBOARDING: OnboardingPlan[] = [ROSA]/*** Load-time invariants. Every one is the failure rule 13 names: something that* looks written, reviewed and intended, and is quietly wrong on the page with* nothing anywhere to say so.*/const rosterNames = new Set(MERIDIAN_PEOPLE.map((p) => p.name))for (const plan of ONBOARDING) {// A plan for a name the roster does not carry is a tab nobody can open.if (!rosterNames.has(plan.person)) {throw new Error(`config/onboarding.ts: "${plan.person}" is not a name in MERIDIAN_PEOPLE, so this plan would ` +`be visible to nobody. Fix the spelling, or add them to the roster first.`,)}// A numbered day with no date renders a heading that cannot be placed in// time; a date on standing material claims it stopped being true afterwards.for (const group of plan.days) {if ((group.day === null) !== (group.date === null)) {throw new Error(`config/onboarding.ts: "${group.title}" has day=${group.day} and date=${group.date}. A ` +`numbered day needs its date, and standing material must have neither.`,)}if (group.date !== null && !/^\d{4}-\d{2}-\d{2}$/.test(group.date)) {throw new Error(`config/onboarding.ts: "${group.title}" has date "${group.date}", which is not ` +`YYYY-MM-DD. The surface formats it as a calendar date and would print it raw.`,)}}// Two groups numbered the same render two identical "Day 3" headings, and// the reader has no way to tell which session they are looking at.const numbered = plan.days.map((g) => g.day).filter((d): d is number => d !== null)const dupes = numbered.filter((d, i) => numbered.indexOf(d) !== i)if (dupes.length) {throw new Error(`config/onboarding.ts: ${plan.person} has more than one group numbered day ${dupes[0]}. ` +`Two days with the same number are indistinguishable on the page.`,)}// The recap. Each of these is the prime invariant pointed at prose: a state// that renders as a different state, with nothing anywhere to say so.for (const group of plan.days) {const recap = group.recapif (!recap) continueif (group.day === null) {throw new Error(`config/onboarding.ts: "${group.title}" is standing material and carries a recap. A ` +`recap is the write-up of a session that happened on a date; standing material has ` +`no date to recap and would print under a heading the document cannot place in time.`,)}if (!recap.sessions.length) {throw new Error(`config/onboarding.ts: the recap for day ${group.day} has no sessions: a heading over ` +`nothing. Leave the recap off until there is something to write under it.`,)}for (const session of recap.sessions) {if (!session.points.length) {throw new Error(`config/onboarding.ts: the session "${session.title ?? '(untitled)'}" on day ` +`${group.day} has no points. Same reason: a heading over nothing.`,)}}// A headless run of bullets floating above a labelled one has no reading.const titled = recap.sessions.filter((x) => x.title !== undefined).lengthif (titled !== 0 && titled !== recap.sessions.length) {throw new Error(`config/onboarding.ts: day ${group.day}'s recap mixes titled and untitled sessions. ` +`Either the day is one run of points or it is several named ones. A nameless run ` +`sitting beside named ones cannot be read.`,)}// An empty string renders as "Accomplished:" followed by nothing, which// reads as "nothing was accomplished". Null is how you say "not recorded".if (recap.accomplished !== null && !recap.accomplished.trim()) {throw new Error(`config/onboarding.ts: day ${group.day}'s \`accomplished\` is blank. That prints as ` +`"Accomplished:" and then nothing, which reads as a judgement rather than an absence. ` +`Use null to say it was not recorded.`,)}if (!recap.label.trim()) {throw new Error(`config/onboarding.ts: day ${group.day}'s recap has no label.`)}if (!plan.recapDoc) {throw new Error(`config/onboarding.ts: ${plan.person} has a recap on day ${group.day} but no ` +`\`recapDoc\`. The bullets would render under no document title. (The reverse, a ` +`recapDoc with no recapped days, is fine, and is what mid-authoring looks like.)`,)}}if (plan.recapDoc && (!plan.recapDoc.title.trim() || !plan.recapDoc.preparedBy.trim())) {throw new Error(`config/onboarding.ts: ${plan.person}'s recapDoc needs both a title and a preparedBy: ` +`the document prints "Prepared by" either way, and an empty one names nobody.`,)}// The master list must be complete. A recording named on a day and missing from// the list at the foot is rule 9's shape: collected, attributed, and then not// shown where the reader was told to look for it.const listed = new Set(plan.resources.flatMap((g) => g.links.map((l) => l.href)))for (const group of plan.days) {for (const rec of group.recordings ?? []) {if (!listed.has(rec.href)) {throw new Error(`config/onboarding.ts: the recording "${rec.label}" on "${group.title}" is not in ` +`\`resources\`. That list is the one place a reader is told to look, so everything ` +`linked anywhere on the plan belongs in it.`,)}}}}/*** Which plans this viewer may read. Empty means no tab at all.** Two ways in, and no third: it is about you, or you read Leadership. Both are* checked against the roster identity Access asserted, never against anything* in a request. An inactive plan is invisible to everyone including its own* subject; that is what the toggle means.*/export function onboardingFor(viewer: Viewer): OnboardingPlan[] {return visiblePlans(ONBOARDING, viewer)}/*** The rule itself, over any list of plans.** Separate from `onboardingFor` so the toggle is testable. A test of the* gating that has to reach into the live `ONBOARDING` array can only assert* what happens to be in it today, so the one case that matters, a plan* switched off, could not be tested without editing the real plan.*/export function visiblePlans(plans: OnboardingPlan[], viewer: Viewer): OnboardingPlan[] {const active = plans.filter((p) => p.active)if (!active.length) return []const mine = active.filter((p) => p.person === viewer.person?.name)if (viewer.seesLeadership) {// Their own first, then everyone else's: a reader who is both looks at// their own plan before they audit somebody else's.return [...mine, ...active.filter((p) => p.person !== viewer.person?.name)]}return mine}/** Is there anything here for this viewer? The tab is rendered on exactly this. */export const hasOnboarding = (viewer: Viewer): boolean => onboardingFor(viewer).length > 0