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

onboarding.ts

Language TypeScript38.7 KB964 lines

config/onboarding.ts

  1. /**
  2. * Onboarding plans: the training material a new person needs in their first
  3. * weeks, and the switch that decides whether they still see it.
  4. *
  5. * A config file and not board state. An editable page backed by KV would be
  6. * more board-owned state (rule 12) carrying the one kind of content that
  7. * genuinely wants review: what a new hire is told to do. Here it is versioned,
  8. * it diffs, and it deploys with everything else. The cost is that adding
  9. * material is an edit rather than a paste.
  10. *
  11. * The toggle is `active`, and it is the whole feature: the tab is on while a
  12. * person is new to the team. Set it false and the tab disappears for everyone, the plan stays in the file, and
  13. * turning it back on is one word. Nothing is deleted to hide it, because a
  14. * deleted plan is a deleted record of what somebody was actually taught.
  15. *
  16. * What else the toggle takes with it: the end-of-day note lives on this tab,
  17. * 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
  18. * back when the plan does, but somebody flipping this to mean "she is not new
  19. * any more" would also, silently, stop the daily read. To stop asking without
  20. * hiding the history, remove her line from `CHECKIN_AUTHORS` in
  21. * config/checkin.ts and leave the plan on.
  22. *
  23. * Who sees it: the person the plan is about, plus anyone who reads the
  24. * Leadership Team's to-dos, so that the people responsible for a new hire can
  25. * see what she is being shown without flagging themselves as new. Everyone
  26. * else gets no tab at all, not an empty one: a tab that opens on somebody
  27. * else's training is a tab about nothing.
  28. *
  29. * `person` is checked against the roster at load. A typo would produce a plan
  30. * nobody can see and no error anywhere (rule 13's failure mode exactly, a
  31. * thing that silently does nothing), so it throws instead.
  32. *
  33. * The shape is days of topics: training split by the day it was covered, each
  34. * day with topic headers and what was covered under them, both levels
  35. * collapsible, and one master resources list at the bottom. That answers the
  36. * question a trainee actually asks ("what did we do on Tuesday?") and lets a
  37. * reader skip a day they have already worked through.
  38. *
  39. * Not every group is a day, and that is deliberate. A `day` of null marks
  40. * standing material: the rhythm she runs every morning, the threads that are
  41. * open right now, what is still owed her, where things live. Filing those
  42. * under the date they happened to be mentioned would date-stamp a thing that is
  43. * true every day, and bury today's work inside a session she has already read.
  44. *
  45. * Links live in one place. `resources` at the foot of the plan is the master
  46. * list, and a day's `recordings` must also appear in it, checked at load
  47. * below. A "master" list that is missing the thing you are looking for is the
  48. * rule-9 failure in its quietest form: collected, attributed, and then not
  49. * shown.
  50. *
  51. * Runtime-neutral: no `node:` imports, because the Worker reads this (rule 7).
  52. */
  53. import { MERIDIAN_PEOPLE } from './meridian-people.js'
  54. import type { Viewer } from '../apps/dashboard/src/viewer.js'
  55. /** A link worth bookmarking. `note` is why it matters, not what it is. */
  56. export interface OnboardingLink {
  57. label: string
  58. href: string
  59. note?: string
  60. }
  61. /** A labelled row: a task, a place, a person to ask. */
  62. export interface OnboardingRow {
  63. label: string
  64. detail: string
  65. /** A short marker: DAILY, BLOCKING, TODAY. Rendered as a chip. */
  66. tag?: string
  67. }
  68. /**
  69. * A topic: one heading, and what was covered under it. The collapsible unit
  70. * inside a day.
  71. *
  72. * No `links` field. Every link on this plan belongs to the master resources
  73. * list, so that there is exactly one place to look (see the file
  74. * header). A day's recordings are the one exception, and they are carried on
  75. * the day rather than on a topic.
  76. */
  77. export interface OnboardingSection {
  78. title: string
  79. intro?: string
  80. bullets?: string[]
  81. rows?: OnboardingRow[]
  82. }
  83. /**
  84. * The recap: the same days, written for somebody who was not in the room.
  85. *
  86. * A second body of text and not a view of the first, because `sections` above
  87. * is reference material addressed to the trainee: "Everything goes in inches"
  88. * means something to her and nothing to a reader being told how her week went.
  89. * The recap is the outward voice: full sentences, curated rather than
  90. * complete, a handful of points where the plan has many more.
  91. *
  92. * A recap derived from `sections` reads as a list of topic titles, which is
  93. * not what a reader wants; the seed below is written by hand. Nothing here is
  94. * generated from `sections`, and the page never claims it is: the two counts
  95. * differ on purpose and the surface says so.
  96. *
  97. * `accomplished` is the trainer's sentence, not the trainee's. It reads like the
  98. * end-of-day note's `did` box and it is not: it is his summary of a session he
  99. * ran, in a committed file that diffs and deploys, shown to the same people who
  100. * can already open the plan. Her notes live in D1 behind `CHECKIN_READERS`,
  101. * which is a shorter list than this page's audience (see config/checkin.ts).
  102. * The module that renders this imports this file and nothing else, and a test
  103. * asserts that import graph, precisely because the field's name is what will
  104. * tempt somebody to wire it to the note store.
  105. */
  106. export interface OnboardingRecapSession {
  107. /** The sub-heading, e.g. "Leads triage". Absent on a single-session day. */
  108. title?: string
  109. /** The curated points. Never empty; checked at load. */
  110. points: string[]
  111. }
  112. export interface OnboardingRecapDay {
  113. /**
  114. * The recap's own name for the day: "Onboarding", "Plinth Quotes",
  115. * "Two Sessions".
  116. *
  117. * Deliberately not `OnboardingDay.title`. That one names the material to the
  118. * person who sat through it; this one names the day to somebody who did not.
  119. */
  120. label: string
  121. /**
  122. * One shape for both kinds of day. A day with several named sessions carries
  123. * one entry per session; a day with one carries one, untitled. A separate
  124. * `points` field for the simple case would mean two render paths and two
  125. * empty states for no gain.
  126. */
  127. sessions: OnboardingRecapSession[]
  128. /**
  129. * What she came out of the day able to do. Null, not optional: writing a
  130. * recap should force the question, and an absent field would let "nobody
  131. * decided" render identically to "there was nothing".
  132. */
  133. accomplished: string | null
  134. }
  135. /** The document's own heading. Absent until somebody writes a recap. */
  136. export interface OnboardingRecapDoc {
  137. /**
  138. * Verbatim, e.g. "Rosa Onboarding: Training Recap".
  139. *
  140. * Authored rather than derived because deriving it needs a first name, and
  141. * the roster holds full names with compound surnames in them.
  142. */
  143. title: string
  144. /** Who wrote the recap. Usually `trainedBy`, but not necessarily. */
  145. preparedBy: string
  146. }
  147. /**
  148. * A day of training, or a standing group that is not a day.
  149. *
  150. * `day` null means standing material (see the file header). `date` is the
  151. * session date and must be present exactly when `day` is, checked at load: a
  152. * numbered day with no date renders a heading that cannot be placed in time,
  153. * and a date on standing material claims it stopped being true afterwards.
  154. */
  155. export interface OnboardingDay {
  156. /** 1, 2, 3 … for a training session. Null for standing material. */
  157. day: number | null
  158. /** ISO date of the session, or null on standing material. */
  159. date: string | null
  160. title: string
  161. intro?: string
  162. /**
  163. * Whether this group starts expanded.
  164. *
  165. * Set by hand, not derived. The obvious derivation (open the newest day)
  166. * has to guess for the standing groups, which are the ones she opens every
  167. * morning and which have no date to be newest by. Adding a new day means
  168. * opening it here and closing the previous one; both failure modes are a
  169. * click.
  170. */
  171. open?: boolean
  172. /** The recording(s) this day was taken from. Must also be in `resources`. */
  173. recordings?: OnboardingLink[]
  174. sections: OnboardingSection[]
  175. /**
  176. * The outward write-up of this day. Absent until somebody writes it, and an
  177. * absent recap on a real training day is a state the document renders rather
  178. * than skips, because a day silently missing from a recap is a day the reader
  179. * is told did not happen.
  180. */
  181. recap?: OnboardingRecapDay
  182. }
  183. /** One heading in the master resources list. */
  184. export interface OnboardingResourceGroup {
  185. title: string
  186. links: OnboardingLink[]
  187. }
  188. export interface OnboardingPlan {
  189. /** Roster display name, exactly. Checked at load; see the file header. */
  190. person: string
  191. role: string
  192. /** ISO date of day one. Rendered as "day N" so nobody has to count. */
  193. startedOn: string
  194. trainedBy?: string
  195. /** The toggle. False hides the tab and keeps the material. */
  196. active: boolean
  197. /** One line, in the trainer's voice, at the top of the tab. */
  198. summary: string
  199. /** Training days and standing groups, in the order they are rendered. */
  200. days: OnboardingDay[]
  201. /** The master list, at the foot of the plan. Every link on the plan is here. */
  202. resources: OnboardingResourceGroup[]
  203. /** Where this came from, named so the reader can go to the original. */
  204. sources: string[]
  205. /**
  206. * The recap document's heading. Present once any day has a `recap`; checked
  207. * at load. The subtitle around it is assembled, not stored: the role comes
  208. * from `role` above and the date range from the days actually rendered, so a
  209. * one-day window can never print a three-day span.
  210. */
  211. recapDoc?: OnboardingRecapDoc
  212. }
  213. /**
  214. * The plan is the trainer's material, not something generated to fill a tab.
  215. *
  216. * It is seeded from the trainer's own documents (a field guide and a short
  217. * slide deck) and the recordings of the training sessions, and the wording is
  218. * kept close to his because the phrasing is the part she will recognise from
  219. * the session. Where the sessions stop, this stops, and the empty state says
  220. * so.
  221. *
  222. * The day split is the recordings' own. Material from the documents sits with
  223. * the day whose session covered it; where a document covered something no
  224. * session did, it is standing material rather than being assigned to a day it
  225. * was not taught on.
  226. *
  227. * Transcripts mishear product names. The meeting recorder writes "Bitrix"
  228. * several different ways; this file spells every system one way, so a search
  229. * for it finds everything.
  230. *
  231. * The clients named in the worked examples are invented for this plan.
  232. */
  233. const ROSA: OnboardingPlan = {
  234. person: 'Rosa Galván',
  235. role: 'Client Success',
  236. startedOn: '2026-09-14',
  237. trainedBy: 'Sam Whitaker',
  238. active: true,
  239. summary:
  240. 'Four sessions so far: the walkthrough, quoting in Plinth, working the leads pipeline, then ' +
  241. 'changing an order after it is placed. What you run every day is at the top; the days are ' +
  242. 'underneath it, and every link is in the master list at the bottom. The SOPs are the source ' +
  243. 'of truth: where this and an SOP disagree, the SOP wins and Sam wants to know about it.',
  244. days: [
  245. // ---------------------------------------------------------------------
  246. // Standing. Not a session. Open, because this is the group she opens
  247. // every morning: the tasks and action items run daily as part of
  248. // operations.
  249. // ---------------------------------------------------------------------
  250. {
  251. day: null,
  252. date: null,
  253. title: 'Every day: the operating rhythm',
  254. open: true,
  255. intro:
  256. 'What the job is when nobody is training you. Chats and calls interrupt all of it, and ' +
  257. 'that is correct: client first, always. Come back to the list afterwards.',
  258. sections: [
  259. {
  260. title: 'Your morning, in order',
  261. rows: [
  262. {
  263. label: '1 · accounts@ inbox',
  264. detail:
  265. 'Work it down. Quote requests, clients needing help, Bitrix / Plinth / Ninety ' +
  266. 'notifications, sample requests.',
  267. tag: 'FIRST',
  268. },
  269. {
  270. label: '2 · Check on your day',
  271. detail: 'This board, My day tab: your own briefing and your oldest items.',
  272. },
  273. {
  274. label: '3 · Log yesterday’s numbers',
  275. detail:
  276. 'The KPI sheet: leads assigned, quotes sent, leads converted. In the morning, ' +
  277. 'so the numbers are ready for the weekly meeting.',
  278. },
  279. {
  280. label: '4 · Work First Reply',
  281. detail: 'Then the rest of Bitrix. This is the day’s real job.',
  282. },
  283. ],
  284. },
  285. {
  286. title: 'Every day, without being asked',
  287. rows: [
  288. {
  289. label: 'Work First Reply',
  290. detail:
  291. 'Clear leads out of that column. Nothing else empties it, and a full column ' +
  292. 'means the queue is not being worked.',
  293. tag: 'DAILY',
  294. },
  295. {
  296. /**
  297. * The only row on this plan that did not come out of the
  298. * trainer's material. It is here
  299. * because the plan is where she reads what is expected of her
  300. * daily, and a daily expectation that appears only as a form on
  301. * the same page is a thing she is being asked for in one place
  302. * and told about in none. The card above says the rest.
  303. */
  304. label: 'Write the end-of-day note',
  305. detail:
  306. 'Three lines at the top of this tab before you log off: what you did, what you ' +
  307. 'are unsure about, what you need. The card names exactly who reads it. The middle ' +
  308. 'box is the point: a question written down tonight gets answered tomorrow.',
  309. tag: 'DAILY',
  310. },
  311. {
  312. label: 'Go through Ask Sam',
  313. detail:
  314. 'End of the day, with Sam. Everything you parked there gets a decision. A full ' +
  315. 'column is fine; it shows where the flow is ambiguous.',
  316. tag: 'DAILY',
  317. },
  318. ],
  319. },
  320. {
  321. title: 'Once a week',
  322. rows: [
  323. {
  324. label: 'Clear Reorder in Progress',
  325. detail:
  326. 'That column collects live conversations so they do not get junked. Empty it ' +
  327. 'weekly.',
  328. tag: 'WEEKLY',
  329. },
  330. ],
  331. },
  332. ],
  333. },
  334. // ---------------------------------------------------------------------
  335. // Standing. Live threads. Open, because a thread waiting on somebody is
  336. // only useful while you can still see it without a click.
  337. // ---------------------------------------------------------------------
  338. {
  339. day: null,
  340. date: null,
  341. title: 'Open right now: yours to chase',
  342. open: true,
  343. intro:
  344. 'Threads that left a session waiting on somebody else. Nobody else is watching for these ' +
  345. 'replies.',
  346. sections: [
  347. {
  348. title: 'Waiting on a client or a supplier',
  349. rows: [
  350. {
  351. label: 'Acme Pet Supply',
  352. detail:
  353. 'Waiting on the final height for two vinyl banners. Asked 15 September; no reply ' +
  354. 'recorded since.',
  355. tag: 'WAITING',
  356. },
  357. {
  358. /**
  359. * A row on a live thread says what it is waiting on now. When the
  360. * supplier answers, the row changes to the next thing; a stale
  361. * row is worse than no row, because she would chase the supplier
  362. * who has already replied.
  363. */
  364. label: 'Globex Conference Group',
  365. detail:
  366. 'The supplier confirmed the custom tower can be built, with a six-week lead ' +
  367. 'time. Sam emailed the client to confirm the timing and CC’d you; waiting on ' +
  368. 'their answer before asking for a final quote.',
  369. tag: 'WAITING',
  370. },
  371. ],
  372. },
  373. {
  374. title: 'Ask Sam',
  375. intro:
  376. 'The parking bucket. It is a real column on the leads Kanban with no automation on ' +
  377. 'it: nothing moves in or out unless somebody moves it.',
  378. bullets: [
  379. 'Anything you are unsure where to put goes there, however many.',
  380. 'You go through it together at the end of the day.',
  381. ],
  382. },
  383. ],
  384. },
  385. // ---------------------------------------------------------------------
  386. // Day 01
  387. // ---------------------------------------------------------------------
  388. {
  389. day: 1,
  390. date: '2026-09-14',
  391. title: 'The role, the tools, and the pipeline',
  392. intro:
  393. 'The walkthrough: what the job is, where everything lives, and how a lead moves from ' +
  394. 'arriving to converted.',
  395. recordings: [
  396. {
  397. label: 'Day 01: Onboarding',
  398. href: 'https://fathom.video/share/EXAMPLE-day-01',
  399. },
  400. ],
  401. recap: {
  402. label: 'Onboarding',
  403. sessions: [
  404. {
  405. points: [
  406. 'Role overview and priorities: client first, and the accounts@ inbox every morning',
  407. 'Daily KPI logging (leads assigned, quotes sent, leads converted)',
  408. 'Bitrix leads pipeline walkthrough: which stages move automatically and which are moved by hand',
  409. 'How to convert a lead correctly: confirm the order in Plinth first, then complete the lead',
  410. 'Tour of this board and of Claude with the Bitrix and Plinth connectors',
  411. ],
  412. },
  413. ],
  414. accomplished: 'full pipeline orientation, tools access set up, started on the First Reply column',
  415. },
  416. sections: [
  417. {
  418. title: 'Client first, always',
  419. intro: 'The one principle everything else is arranged around.',
  420. bullets: [
  421. 'A client waiting beats anything else on your list. Chats and calls interrupt the queue and that is correct.',
  422. 'In-hand date = the deadline: the last day the client can receive it. It is the number that matters on every order.',
  423. ],
  424. },
  425. {
  426. title: 'The Bitrix leads pipeline',
  427. intro: 'The CRM for everything with revenue attached. Kanban, and the columns mean things.',
  428. bullets: [
  429. '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.',
  430. 'Only leads and reorders belong there. Marketing, notifications, a verification code you triggered yourself: all Junk.',
  431. '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.',
  432. ],
  433. },
  434. {
  435. title: 'Converting a won lead',
  436. intro: 'Check first, then close it out.',
  437. bullets: [
  438. 'Check Plinth first and confirm the order actually exists.',
  439. 'Complete the lead, pick the outcome (Converted Lead or Reorder Completed), fill the qualifier fields and save.',
  440. 'Converted or reorder? Open the client and look for previous orders. None means a new client.',
  441. ],
  442. },
  443. {
  444. title: 'Two shipments, two tracking numbers',
  445. intro: '“I got a tracking number but only the hardware showed up.” A question you will hear often.',
  446. bullets: [
  447. 'Hardware and printed graphics often ship separately, on different days, with their own tracking numbers. That is normal.',
  448. 'What to say: it is on the way, and the hardware tracking goes out first so everything arrives before the in-hand date.',
  449. ],
  450. },
  451. {
  452. title: 'Claude, with Bitrix and Plinth connected',
  453. intro: 'Guardrails first.',
  454. bullets: [
  455. 'Plinth is read-only from Claude. It cannot change an order, and that is a feature.',
  456. 'Bitrix can write. Confirm before it changes a stage or a field on a real lead.',
  457. 'It is AI. Verify anything you are about to put in front of a client.',
  458. ],
  459. },
  460. ],
  461. },
  462. // ---------------------------------------------------------------------
  463. // Day 02
  464. // ---------------------------------------------------------------------
  465. {
  466. day: 2,
  467. date: '2026-09-15',
  468. title: 'Quoting in Plinth, end to end',
  469. intro:
  470. 'One quote written start to finish, then one that could not be quoted as asked. The ' +
  471. 'worked example is what makes the next one obvious.',
  472. recordings: [
  473. {
  474. label: 'Day 02: Quoting in Plinth',
  475. href: 'https://fathom.video/share/EXAMPLE-day-02',
  476. },
  477. ],
  478. recap: {
  479. label: 'Plinth Quotes',
  480. sessions: [
  481. {
  482. points: [
  483. 'Built a quote start to finish in Plinth: product, dimensions, finishing, price, shipping',
  484. 'What to do with an incomplete or unworkable request: ask the client, or propose an alternative',
  485. 'Worked a live example: a banner request with a dimension that did not add up',
  486. 'When a custom product goes to a supplier for feasibility instead of being quoted directly',
  487. ],
  488. },
  489. ],
  490. accomplished: 'sent her first quotes on her own and messaged clients directly in Bitrix',
  491. },
  492. sections: [
  493. {
  494. title: 'Writing a quote in Plinth',
  495. rows: [
  496. {
  497. label: 'Everything goes in inches',
  498. detail:
  499. 'An 8 × 12 ft banner is 96 by 144. Multiply by twelve before it goes in the ' +
  500. 'box, every time.',
  501. tag: 'EASY TO MISS',
  502. },
  503. {
  504. label: 'Custom, or predefined?',
  505. detail:
  506. 'A predefined product carries an SKU and the options already chosen. A custom ' +
  507. 'request is free text: copy it somewhere first, then add the real product ' +
  508. 'properly.',
  509. },
  510. {
  511. label: 'Then check it actually went',
  512. detail: 'The email log in Plinth shows whether the quote email left.',
  513. },
  514. ],
  515. },
  516. {
  517. title: 'When you cannot quote it yet',
  518. rows: [
  519. {
  520. label: 'A number that looks wrong',
  521. detail: 'Ask the client before you price it. A quick question beats a remake.',
  522. },
  523. {
  524. label: 'Something we cannot make',
  525. detail: 'Say so, and offer the closest thing we can make instead.',
  526. },
  527. ],
  528. },
  529. {
  530. title: 'Worked example: the Acme Pet Supply banners',
  531. bullets: [
  532. 'The request gave a height that could not match the width they described.',
  533. 'Rather than guess, the quote waited on one short question to the client.',
  534. 'The thread is yours now; it is on the list at the top.',
  535. ],
  536. },
  537. ],
  538. },
  539. // ---------------------------------------------------------------------
  540. // Day 03. Two sessions, so two recordings.
  541. // ---------------------------------------------------------------------
  542. {
  543. day: 3,
  544. date: '2026-09-16',
  545. title: 'Working the pipeline, and a custom quote by email',
  546. intro:
  547. 'A triage pass through the leads board, then a custom request quoted by email with a ' +
  548. 'supplier in the loop.',
  549. recordings: [
  550. {
  551. label: 'Day 03a: Leads triage',
  552. href: 'https://fathom.video/share/EXAMPLE-day-03a',
  553. },
  554. {
  555. label: 'Day 03b: Custom quote by email',
  556. href: 'https://fathom.video/share/EXAMPLE-day-03b',
  557. },
  558. ],
  559. recap: {
  560. label: 'Two Sessions',
  561. sessions: [
  562. {
  563. title: 'Leads triage',
  564. points: [
  565. 'Worked the First Reply column lead by lead, deciding where each one belonged',
  566. 'Converted lead or reorder completed: how to tell from the client’s order history',
  567. 'A lead counts as converted when the order is paid, not when it ships',
  568. ],
  569. },
  570. {
  571. title: 'Custom quote by email',
  572. points: [
  573. 'Used the client’s budget to decide the spec before asking for a price',
  574. 'Confirmed the lead time with the supplier before requesting a quote',
  575. ],
  576. },
  577. ],
  578. accomplished: 'triaged the First Reply column with Sam and drafted a custom quote by email',
  579. },
  580. sections: [
  581. {
  582. title: 'First Reply means your first reply',
  583. bullets: [
  584. 'A lead sits in First Reply until a person has actually answered the client.',
  585. 'An auto-reply does not count, even though it can move the lead there.',
  586. ],
  587. },
  588. {
  589. title: 'A lead converts when they pay, not when it ships',
  590. bullets: [
  591. 'An order that is paid and in production is already a converted lead.',
  592. 'Then the only question left is new client or returning client.',
  593. ],
  594. },
  595. {
  596. title: 'Client tiers',
  597. bullets: [
  598. 'Clients are Retail, Palladium Partner, Platinum Partner, Iridium Partner or Rhodium Partner. The tier goes in the qualifier field when you convert.',
  599. 'Clients get an email when they move up a tier. That outreach is not yours yet.',
  600. ],
  601. },
  602. {
  603. title: 'Worked example: the Globex tower',
  604. bullets: [
  605. 'Globex Conference Group asked for a custom display tower that is not on the site.',
  606. 'Their budget decided the size before anybody asked for a price.',
  607. 'The supplier confirmed it could be built and gave a lead time first; the quote comes after the client accepts the timing.',
  608. ],
  609. },
  610. ],
  611. },
  612. // ---------------------------------------------------------------------
  613. // Day 04. Open, because it is the newest day.
  614. // ---------------------------------------------------------------------
  615. {
  616. day: 4,
  617. date: '2026-09-17',
  618. title: 'Changing an order after it is placed',
  619. open: true,
  620. intro: 'The order is paid and the client wants something different. How to change it cleanly.',
  621. recordings: [
  622. {
  623. label: 'Day 04: Order changes',
  624. href: 'https://fathom.video/share/EXAMPLE-day-04',
  625. },
  626. ],
  627. recap: {
  628. label: 'Order Changes',
  629. sessions: [
  630. {
  631. points: [
  632. 'Modifying a placed order: record the original total, swap the product, recheck shipping',
  633. 'Charging or refunding the difference, and marking the order so production knows',
  634. 'Checking tracking before sending it to a client',
  635. ],
  636. },
  637. ],
  638. // Not written up yet. Null, not a blank: the recap says "not recorded".
  639. accomplished: null,
  640. },
  641. sections: [
  642. {
  643. title: 'Modifying an order',
  644. rows: [
  645. {
  646. label: 'Screenshot the total first',
  647. detail: 'Before you touch anything, so the difference can be worked out afterwards.',
  648. tag: 'FIRST',
  649. },
  650. {
  651. label: 'Swap, do not edit',
  652. detail: 'Remove the old product line and add the new one.',
  653. },
  654. {
  655. label: 'Shipping does not recalculate itself',
  656. detail: 'Check it by hand after the swap.',
  657. },
  658. {
  659. label: 'Charge the difference',
  660. detail: 'Send a payment request for anything owed, or refund anything overpaid.',
  661. },
  662. ],
  663. },
  664. {
  665. title: 'Check the tracking before you send it',
  666. bullets: [
  667. 'Open the tracking link yourself first. A number that does not resolve yet is a confused client tomorrow.',
  668. ],
  669. },
  670. ],
  671. },
  672. // ---------------------------------------------------------------------
  673. // Standing. What is still owed, and where things live.
  674. // ---------------------------------------------------------------------
  675. {
  676. day: null,
  677. date: null,
  678. title: 'Waiting on Sam',
  679. sections: [
  680. {
  681. title: 'Still owed',
  682. rows: [
  683. {
  684. label: 'Reorder in Progress',
  685. detail: 'A walkthrough of how that column is worked week to week.',
  686. },
  687. {
  688. label: 'The Plinth status flowchart',
  689. detail: 'One page showing how an order moves from placed to delivered.',
  690. },
  691. ],
  692. },
  693. ],
  694. },
  695. {
  696. day: null,
  697. date: null,
  698. title: 'Reference',
  699. sections: [
  700. {
  701. title: 'Who to ask',
  702. rows: [
  703. { label: 'Sam', detail: 'Anything client success. Default to him first.' },
  704. { label: 'Theo', detail: 'Office and operations: claims, shipping problems, product feasibility.' },
  705. { label: 'Marco', detail: 'Designer.' },
  706. { label: 'Rafael', detail: 'Designer, and the production owner on your orders.' },
  707. { label: 'Nora', detail: 'General manager. Runs the weekly meeting and Ninety.' },
  708. ],
  709. },
  710. {
  711. title: 'Where things live',
  712. rows: [
  713. { label: 'Leads, contacts', detail: 'Bitrix24 → CRM → Leads → Kanban' },
  714. { label: 'Quotes', detail: 'Plinth → Quote Management' },
  715. { label: 'Orders', detail: 'Plinth → Orders' },
  716. { label: 'Did my email send?', detail: 'Plinth → Email Log' },
  717. { label: 'Your day, every inbox', detail: 'This board' },
  718. ],
  719. },
  720. ],
  721. },
  722. ],
  723. resources: [
  724. {
  725. title: 'The SOPs: source of truth',
  726. links: [
  727. {
  728. label: 'SOP index',
  729. href: 'https://docs.google.com/spreadsheets/d/EXAMPLE',
  730. note: 'Every SOP we have, in one sheet.',
  731. },
  732. {
  733. label: 'Client Success Position SOP',
  734. href: 'https://docs.google.com/document/d/EXAMPLE',
  735. },
  736. {
  737. label: 'How to Manage Leads in Bitrix24',
  738. href: 'https://docs.google.com/document/d/EXAMPLE',
  739. },
  740. ],
  741. },
  742. {
  743. title: 'Plinth',
  744. links: [
  745. { label: 'Orders', href: 'https://meridiandisplay.example/admin/orders' },
  746. { label: 'Quote Management', href: 'https://meridiandisplay.example/admin/quotes' },
  747. ],
  748. },
  749. {
  750. title: 'The spreadsheets',
  751. links: [
  752. {
  753. label: 'KPI sheet',
  754. href: 'https://docs.google.com/spreadsheets/d/EXAMPLE',
  755. note: 'Your daily numbers go here.',
  756. },
  757. ],
  758. },
  759. {
  760. title: 'The training recordings',
  761. links: [
  762. { label: 'Day 01: Onboarding', href: 'https://fathom.video/share/EXAMPLE-day-01' },
  763. { label: 'Day 02: Quoting in Plinth', href: 'https://fathom.video/share/EXAMPLE-day-02' },
  764. { label: 'Day 03a: Leads triage', href: 'https://fathom.video/share/EXAMPLE-day-03a' },
  765. {
  766. label: 'Day 03b: Custom quote by email',
  767. href: 'https://fathom.video/share/EXAMPLE-day-03b',
  768. },
  769. { label: 'Day 04: Order changes', href: 'https://fathom.video/share/EXAMPLE-day-04' },
  770. ],
  771. },
  772. ],
  773. recapDoc: {
  774. title: 'Rosa Onboarding: Training Recap',
  775. preparedBy: 'Sam Whitaker',
  776. },
  777. sources: [
  778. 'Training day 04, 2026-09-17 (Fathom recording)',
  779. 'Training day 03, 2026-09-16, two sessions (Fathom recordings)',
  780. 'Training day 02, 2026-09-15 (Fathom recording)',
  781. 'Training day 01, 2026-09-14 (Fathom recording)',
  782. 'Training Recap, 2026-09-17 (Sam’s write-up; the recap text is his)',
  783. 'Client Success Field Guide (2026-09-14)',
  784. 'Client Success Onboarding slides (2026-09-14)',
  785. 'SOP: Client Success Position',
  786. 'SOP: How to Manage Leads in Bitrix24',
  787. ],
  788. }
  789. export const ONBOARDING: OnboardingPlan[] = [ROSA]
  790. /**
  791. * Load-time invariants. Every one is the failure rule 13 names: something that
  792. * looks written, reviewed and intended, and is quietly wrong on the page with
  793. * nothing anywhere to say so.
  794. */
  795. const rosterNames = new Set(MERIDIAN_PEOPLE.map((p) => p.name))
  796. for (const plan of ONBOARDING) {
  797. // A plan for a name the roster does not carry is a tab nobody can open.
  798. if (!rosterNames.has(plan.person)) {
  799. throw new Error(
  800. `config/onboarding.ts: "${plan.person}" is not a name in MERIDIAN_PEOPLE, so this plan would ` +
  801. `be visible to nobody. Fix the spelling, or add them to the roster first.`,
  802. )
  803. }
  804. // A numbered day with no date renders a heading that cannot be placed in
  805. // time; a date on standing material claims it stopped being true afterwards.
  806. for (const group of plan.days) {
  807. if ((group.day === null) !== (group.date === null)) {
  808. throw new Error(
  809. `config/onboarding.ts: "${group.title}" has day=${group.day} and date=${group.date}. A ` +
  810. `numbered day needs its date, and standing material must have neither.`,
  811. )
  812. }
  813. if (group.date !== null && !/^\d{4}-\d{2}-\d{2}$/.test(group.date)) {
  814. throw new Error(
  815. `config/onboarding.ts: "${group.title}" has date "${group.date}", which is not ` +
  816. `YYYY-MM-DD. The surface formats it as a calendar date and would print it raw.`,
  817. )
  818. }
  819. }
  820. // Two groups numbered the same render two identical "Day 3" headings, and
  821. // the reader has no way to tell which session they are looking at.
  822. const numbered = plan.days.map((g) => g.day).filter((d): d is number => d !== null)
  823. const dupes = numbered.filter((d, i) => numbered.indexOf(d) !== i)
  824. if (dupes.length) {
  825. throw new Error(
  826. `config/onboarding.ts: ${plan.person} has more than one group numbered day ${dupes[0]}. ` +
  827. `Two days with the same number are indistinguishable on the page.`,
  828. )
  829. }
  830. // The recap. Each of these is the prime invariant pointed at prose: a state
  831. // that renders as a different state, with nothing anywhere to say so.
  832. for (const group of plan.days) {
  833. const recap = group.recap
  834. if (!recap) continue
  835. if (group.day === null) {
  836. throw new Error(
  837. `config/onboarding.ts: "${group.title}" is standing material and carries a recap. A ` +
  838. `recap is the write-up of a session that happened on a date; standing material has ` +
  839. `no date to recap and would print under a heading the document cannot place in time.`,
  840. )
  841. }
  842. if (!recap.sessions.length) {
  843. throw new Error(
  844. `config/onboarding.ts: the recap for day ${group.day} has no sessions: a heading over ` +
  845. `nothing. Leave the recap off until there is something to write under it.`,
  846. )
  847. }
  848. for (const session of recap.sessions) {
  849. if (!session.points.length) {
  850. throw new Error(
  851. `config/onboarding.ts: the session "${session.title ?? '(untitled)'}" on day ` +
  852. `${group.day} has no points. Same reason: a heading over nothing.`,
  853. )
  854. }
  855. }
  856. // A headless run of bullets floating above a labelled one has no reading.
  857. const titled = recap.sessions.filter((x) => x.title !== undefined).length
  858. if (titled !== 0 && titled !== recap.sessions.length) {
  859. throw new Error(
  860. `config/onboarding.ts: day ${group.day}'s recap mixes titled and untitled sessions. ` +
  861. `Either the day is one run of points or it is several named ones. A nameless run ` +
  862. `sitting beside named ones cannot be read.`,
  863. )
  864. }
  865. // An empty string renders as "Accomplished:" followed by nothing, which
  866. // reads as "nothing was accomplished". Null is how you say "not recorded".
  867. if (recap.accomplished !== null && !recap.accomplished.trim()) {
  868. throw new Error(
  869. `config/onboarding.ts: day ${group.day}'s \`accomplished\` is blank. That prints as ` +
  870. `"Accomplished:" and then nothing, which reads as a judgement rather than an absence. ` +
  871. `Use null to say it was not recorded.`,
  872. )
  873. }
  874. if (!recap.label.trim()) {
  875. throw new Error(`config/onboarding.ts: day ${group.day}'s recap has no label.`)
  876. }
  877. if (!plan.recapDoc) {
  878. throw new Error(
  879. `config/onboarding.ts: ${plan.person} has a recap on day ${group.day} but no ` +
  880. `\`recapDoc\`. The bullets would render under no document title. (The reverse, a ` +
  881. `recapDoc with no recapped days, is fine, and is what mid-authoring looks like.)`,
  882. )
  883. }
  884. }
  885. if (plan.recapDoc && (!plan.recapDoc.title.trim() || !plan.recapDoc.preparedBy.trim())) {
  886. throw new Error(
  887. `config/onboarding.ts: ${plan.person}'s recapDoc needs both a title and a preparedBy: ` +
  888. `the document prints "Prepared by" either way, and an empty one names nobody.`,
  889. )
  890. }
  891. // The master list must be complete. A recording named on a day and missing from
  892. // the list at the foot is rule 9's shape: collected, attributed, and then not
  893. // shown where the reader was told to look for it.
  894. const listed = new Set(plan.resources.flatMap((g) => g.links.map((l) => l.href)))
  895. for (const group of plan.days) {
  896. for (const rec of group.recordings ?? []) {
  897. if (!listed.has(rec.href)) {
  898. throw new Error(
  899. `config/onboarding.ts: the recording "${rec.label}" on "${group.title}" is not in ` +
  900. `\`resources\`. That list is the one place a reader is told to look, so everything ` +
  901. `linked anywhere on the plan belongs in it.`,
  902. )
  903. }
  904. }
  905. }
  906. }
  907. /**
  908. * Which plans this viewer may read. Empty means no tab at all.
  909. *
  910. * Two ways in, and no third: it is about you, or you read Leadership. Both are
  911. * checked against the roster identity Access asserted, never against anything
  912. * in a request. An inactive plan is invisible to everyone including its own
  913. * subject; that is what the toggle means.
  914. */
  915. export function onboardingFor(viewer: Viewer): OnboardingPlan[] {
  916. return visiblePlans(ONBOARDING, viewer)
  917. }
  918. /**
  919. * The rule itself, over any list of plans.
  920. *
  921. * Separate from `onboardingFor` so the toggle is testable. A test of the
  922. * gating that has to reach into the live `ONBOARDING` array can only assert
  923. * what happens to be in it today, so the one case that matters, a plan
  924. * switched off, could not be tested without editing the real plan.
  925. */
  926. export function visiblePlans(plans: OnboardingPlan[], viewer: Viewer): OnboardingPlan[] {
  927. const active = plans.filter((p) => p.active)
  928. if (!active.length) return []
  929. const mine = active.filter((p) => p.person === viewer.person?.name)
  930. if (viewer.seesLeadership) {
  931. // Their own first, then everyone else's: a reader who is both looks at
  932. // their own plan before they audit somebody else's.
  933. return [...mine, ...active.filter((p) => p.person !== viewer.person?.name)]
  934. }
  935. return mine
  936. }
  937. /** Is there anything here for this viewer? The tab is rendered on exactly this. */
  938. export const hasOnboarding = (viewer: Viewer): boolean => onboardingFor(viewer).length > 0