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

fixtures.ts

Language TypeScript19.7 KB454 lines

apps/dashboard/src/fixtures.ts

  1. /**
  2. * Sample data shaped like the real thing, so `pnpm dashboard --fixtures` renders
  3. * without credentials.
  4. *
  5. * Everything here is invented: counts, stage mix, customer names, lead and task
  6. * titles. This exists so layout and classification bugs are found before they
  7. * reach a live page, and so the page can be shown to someone who has no
  8. * Plinth access.
  9. */
  10. import { isJunkTask, type Lead, type Task } from '@meridian/bitrix-client'
  11. import type { OrderLine } from '@meridian/plinth-client'
  12. import { assignmentSpread, localDay, MERIDIAN_TZ, type Todo } from '@meridian/ninety-client'
  13. import type { TrackerTask } from '@meridian/sheets-client'
  14. import type { MeetingRecord, Snapshot } from './collect.js'
  15. import { PLINTH_SEED, SEED_EXPORTED_AT } from './plinth-seed.js'
  16. import { FATHOM_SEED } from './fathom-seed.js'
  17. import { attributeFathomAssignee } from '../../../config/fathom-people.js'
  18. import { displayName } from '../../../config/plinth-people.js'
  19. /**
  20. * `movedHoursAgo` drives the Elsewhere feed, which reads `updatedAt`. With it
  21. * null on every fixture lead, that surface would only ever render its empty
  22. * state. A few leads move in the last day; the rest do not.
  23. */
  24. const lead = (
  25. id: string,
  26. title: string,
  27. statusId: string,
  28. daysAgo: number,
  29. company?: string,
  30. movedHoursAgo?: number,
  31. ): Lead => ({
  32. id,
  33. title,
  34. company: company ?? null,
  35. statusId,
  36. createdAt: new Date(Date.now() - daysAgo * 86_400_000).toISOString(),
  37. updatedAt:
  38. movedHoursAgo === undefined ? null : new Date(Date.now() - movedHoursAgo * 3_600_000).toISOString(),
  39. assignedById: '1',
  40. amount: 0,
  41. noise: false,
  42. noiseReason: null,
  43. // Fixture leads are all in configured process stages, so Bitrix's own
  44. // grouping is 'P'. Real data also carries 'S'/'F' on terminal stages.
  45. semantic: 'P' as const,
  46. })
  47. const noiseLead = (id: string, title: string): Lead => ({
  48. ...lead(id, title, 'UC_VOHNVF', 1),
  49. noise: true,
  50. noiseReason: 'automated',
  51. })
  52. /**
  53. * Bitrix user ids, as user.get returns them. Roster people plus the accounts
  54. * worth reproducing.
  55. *
  56. * Numbers, because Bitrix sends a numeric user id and nothing else. A fixture
  57. * that passed display names would reproduce the shape a name-matching bug
  58. * expects rather than the shape the API sends, and a fixture that agrees with
  59. * a bug cannot catch it. Fixtures are the contract: real structure, invented
  60. * values.
  61. */
  62. const BITRIX = {
  63. sam: '1924',
  64. hazel: '2736',
  65. marco: '7108',
  66. ines: '7404',
  67. theo: '1698',
  68. nora: '1',
  69. /** A former operations account, inactive. Holds a series of dead FedEx rows. */
  70. departed: '26',
  71. /** "Meeting Room": a resource, not a person. Holds the `test` rows. */
  72. meetingRoom: '196',
  73. /** A former marketing account, inactive, that still holds open tasks. */
  74. offRoster: '4',
  75. } as const
  76. const task = (
  77. id: string,
  78. title: string,
  79. responsible: string | null,
  80. deadline: string | null,
  81. ): Task => ({
  82. id,
  83. title,
  84. responsibleId: responsible,
  85. deadline,
  86. createdAt: new Date(Date.now() - 20 * 86_400_000).toISOString(),
  87. ageDays: 20,
  88. overdue: deadline ? Date.parse(deadline) < Date.now() : false,
  89. ...isJunkTask(title),
  90. junkReason: isJunkTask(title).reason,
  91. })
  92. function repeat<T>(n: number, fn: (i: number) => T): T[] {
  93. return Array.from({ length: n }, (_, i) => fn(i))
  94. }
  95. const LEADS: Lead[] = [
  96. ...repeat(23, (i) => lead(`r${i}`, `Reorder: client ${i + 1}`, '7', 3 + i, 'Sample Co')),
  97. ...repeat(20, (i) => lead(`n${i}`, `No reply: prospect ${i + 1}`, '2', 20 + i * 2)),
  98. ...repeat(8, (i) => lead(`q${i}`, `Quote sent: prospect ${i + 1}`, 'UC_E1JC2S', 5 + i)),
  99. ...repeat(5, (i) => lead(`w${i}`, `Waiting on client ${i + 1}`, '9', 9 + i)),
  100. ...repeat(4, (i) => lead(`p${i}`, `Parked ${i + 1}`, '8', 40 + i)),
  101. ...repeat(3, (i) => lead(`u${i}`, `New inquiry ${i + 1}`, 'UC_VOHNVF', i, undefined, 5 + i * 4)),
  102. lead('c1', 'Client replied: needs an answer', 'UC_Y4AE5V', 2, 'Acme Events', 3),
  103. ...repeat(2, (i) => lead(`a${i}`, `Quote approved ${i + 1}`, 'IN_PROCESS', 6 + i, 'Sample Co', 9 + i * 6)),
  104. lead('f1', 'First reply', 'PROCESSED', 1),
  105. lead('s1', 'Consultation scheduled', 'UC_BB3F0W', 2),
  106. lead('as1', 'Allocated', 'NEW', 1, 'Cedar Hollow HOA', 11),
  107. noiseLead('x1', 'Security alert: new passkey added to your Claude account'),
  108. noiseLead('x2', '1 555-010-0199 - Incoming call'),
  109. noiseLead('x3', 'Complete CRM form "Website Chat"'),
  110. ]
  111. /**
  112. * What the product row reads when the order reads X. Taken from the shape of
  113. * live Plinth data: the two catalogs share some names and not others, and
  114. * `Proof Out` is one of the ones that exists only at order level: an
  115. * order waiting on client approval carries a product row of "Awaiting Proof or
  116. * Design Approval", which is the product catalog's name for the same wait.
  117. */
  118. const PRODUCT_FOR_ORDER: Record<string, string> = {
  119. 'Proof Out': 'Proof With Client', // class 'client': waiting on them
  120. 'File Check': 'File Check',
  121. 'Proof Signed Off': 'Proof Signed Off',
  122. 'Design Requested': 'Design Requested',
  123. }
  124. /**
  125. * Order lines built from the Plinth seed (apps/dashboard/src/plinth-seed.ts),
  126. * one row per order number. Product-level fields that the seed does not carry
  127. * are empty rather than made up: a fixture that fabricates them would make the
  128. * render look right while testing nothing about how real data flows.
  129. */
  130. const LINES: OrderLine[] = PLINTH_SEED.map((o, i) => ({
  131. orderId: o.orderId ?? 90000 + i,
  132. ordersProductsId: null,
  133. productId: null,
  134. orderStatus: o.orderStatus,
  135. orderStatusId: 0,
  136. /**
  137. * A real product status, not the order's echoed back. Mirroring the order
  138. * status would put order-only statuses such as `Proof Out` into the product
  139. * field, where they hit the unknown-means-ours default, and no test could
  140. * exercise the two fields disagreeing, which is the entire subject of the
  141. * rule they feed (see classifyLine).
  142. */
  143. productStatus: PRODUCT_FOR_ORDER[o.orderStatus] ?? o.orderStatus,
  144. productStatusId: null,
  145. // The export carries neither a product title nor a customer name: it is an
  146. // order-level report. Echoing the order number into both columns would make
  147. // three columns say the same thing. Empty is honest; the render turns it into
  148. // a printed "not in export".
  149. productTitle: '',
  150. quantity: 1,
  151. price: 0,
  152. customer: '',
  153. company: null,
  154. placedAt: o.placedAt,
  155. amount: 0,
  156. vendor: null,
  157. userGroup: o.userGroup,
  158. productionOwner: displayName(o.productionOwner),
  159. accountOwner: displayName(o.accountOwner),
  160. // Against the export date, not today. See the note in plinth-seed.ts:
  161. // measuring a frozen snapshot against a live clock ages it one day per day.
  162. ageDays: o.placedAt
  163. ? Math.floor((Date.parse(SEED_EXPORTED_AT) - Date.parse(o.placedAt)) / 86_400_000)
  164. : null,
  165. }))
  166. /**
  167. * Bitrix tasks, shaped like the live ones, including the junk, because the junk
  168. * is the point. Duplicate FedEx rows and Bitrix's onboarding defaults turn up
  169. * in real accounts and must be visibly excluded rather than quietly absent.
  170. */
  171. const TASKS: Task[] = [
  172. // Junk, attributed the way it tends to be live: the FedEx series to a
  173. // departed account and the test rows to a meeting room.
  174. ...repeat(16, (i) => task(`f${i}`, 'FedEx Invoice Review', BITRIX.departed, null)),
  175. ...repeat(4, (i) => task(`t${i}`, 'test', BITRIX.meetingRoom, null)),
  176. task('b1', 'Fill out profile', BITRIX.nora, null),
  177. task('b2', 'Invite colleagues', BITRIX.nora, null),
  178. task('b3', 'Download Bitrix24 application', BITRIX.nora, null),
  179. // Real work, held by the roster.
  180. task('r1', 'Update the site hosting plan', BITRIX.ines, '2026-07-27'),
  181. task('r2', 'Compress product images on the banner pages', BITRIX.ines, '2026-08-14'),
  182. task('r3', 'Book team photos for the About page', BITRIX.theo, '2026-07-15'),
  183. task('r4', 'Tidewater Studios / Event backdrop design', BITRIX.marco, null),
  184. task('r5', 'Research trade show organizers', BITRIX.sam, '2026-08-20'),
  185. task('r6', 'Meet with Nora', BITRIX.hazel, null),
  186. // Real work held by an account off the roster. Expected, and must be counted
  187. // on Data health rather than absorbed. Most real tasks can be like this,
  188. // and the board deliberately shows none of them.
  189. task('r7', 'Fix the old quote form (April, 2024)', BITRIX.offRoster, null),
  190. // No responsible party at all. Different from off-roster and must not merge
  191. // with it: nobody has been asked to do this one.
  192. task('r8', 'Test a new product page layout', null, null),
  193. ]
  194. /**
  195. /**
  196. * Ninety team ids, from config/ninety-teams.ts.
  197. *
  198. * Live data mixes all four cases (Leadership, Operations, All Hands, no team),
  199. * and fixtures are the contract: a single made-up team id on every row would
  200. * make `isRestrictedTeam()` say yes to everything and leave the All Hands and
  201. * untagged paths untested.
  202. */
  203. const TEAM = {
  204. leadership: '3daec27fdbd4c3a983e95864',
  205. operations: '20b8a35b8edd3ed30b2d8f30',
  206. allHands: 'eb3d8bb215506d3263d47cac',
  207. } as const
  208. const TEAM_NAME: Record<string, string> = {
  209. [TEAM.leadership]: 'Leadership Team',
  210. [TEAM.operations]: 'Operations Team',
  211. [TEAM.allHands]: 'All Hands',
  212. }
  213. const todo = (
  214. id: string,
  215. title: string,
  216. ownerName: string,
  217. dueInDays: number,
  218. opts: { milestoneId?: string; teamId?: string | null } = {},
  219. ): Todo => {
  220. const due = new Date(Date.now() + dueInDays * 86_400_000).toISOString()
  221. return {
  222. id,
  223. title,
  224. description: '',
  225. descriptionHtml: '',
  226. dueDate: due,
  227. dueDateLocal: localDay(due, MERIDIAN_TZ),
  228. dueLabel: '',
  229. completed: false,
  230. completedDate: null,
  231. archived: false,
  232. deleted: false,
  233. owner: { id: `u-${ownerName.toLowerCase().replace(/\W+/g, '')}`, name: ownerName },
  234. createdBy: { id: 'u-seed', name: ownerName },
  235. // `teamId: undefined` in opts means All Hands; an explicit `null` means the
  236. // to-do carries no team at all, which isRestrictedTeam() treats as restricted.
  237. teamId: opts.teamId === undefined ? TEAM.allHands : opts.teamId,
  238. teamName: opts.teamId === undefined ? TEAM_NAME[TEAM.allHands]! : (TEAM_NAME[opts.teamId ?? ''] ?? null),
  239. isPersonal: false,
  240. repeat: null,
  241. seriesId: null,
  242. milestoneId: opts.milestoneId ?? null,
  243. followers: [],
  244. createdAt: null,
  245. raw: { _id: id } as Todo['raw'],
  246. }
  247. }
  248. const LEADERSHIP = { teamId: TEAM.leadership }
  249. /** No team at all. Live rows like this exist, and they count as restricted. */
  250. const UNTAGGED = { teamId: null }
  251. /**
  252. * Ninety to-dos, shaped like a live company sweep.
  253. *
  254. * The important property being reproduced here is not the count: it is that
  255. * the hiring to-do sits on three people and the photo refresh on two. Shared
  256. * to-dos are common live, and a fixture set of purely solo to-dos would let the
  257. * assignments-vs-commitments distinction go untested.
  258. *
  259. * Names are roster names because the people board keys on them; titles are
  260. * invented.
  261. */
  262. const TODOS: Todo[] = [
  263. // One commitment, three owners. The case that breaks a naive count. Hiring is
  264. // Leadership work, so this is also the shared-AND-restricted case.
  265. todo('n1', 'Hire a client success associate & write the hiring SOP', 'Sam Whitaker', 37, LEADERSHIP),
  266. todo('n2', 'Hire a client success associate & write the hiring SOP', 'Nora Lindqvist', 37, LEADERSHIP),
  267. todo('n3', 'Hire a client success associate & write the hiring SOP', 'Theo Bassett', 37, LEADERSHIP),
  268. // One commitment, two owners. All Hands: the rollup the Google Sheet sits
  269. // behind.
  270. todo('n4', 'Weekly product photo refresh', 'Ines Carvalho', 5),
  271. todo('n5', 'Weekly product photo refresh', 'Rafael Montero', 5),
  272. // Solo work, including two that are properly late.
  273. todo('n6', 'Finish the site search project with Northgate', 'Sam Whitaker', 11, { milestoneId: 'ms-ai' }),
  274. todo('n7', 'Send Northgate feedback on the quote form', 'Sam Whitaker', -9),
  275. todo('n8', 'Follow up with Northgate on the redirect list', 'Sam Whitaker', -3),
  276. // Leadership, late, and owned by the one person who reads the exec view.
  277. todo('n9', 'Update KPIs for client success and production', 'Nora Lindqvist', 11, LEADERSHIP),
  278. todo('n10', 'Find a second supplier for tent frames', 'Marco Ibáñez', 20, {
  279. teamId: TEAM.operations,
  280. }),
  281. // Carries no team, so it is restricted for want of a tag rather than by intent.
  282. todo('n11', 'Coordinate backdrop images', 'Theo Bassett', 4, UNTAGGED),
  283. ]
  284. /**
  285. * Content Creation tracker rows, shaped like the live sheet.
  286. *
  287. * The properties being reproduced, in order of how badly each one bit:
  288. *
  289. * - Owners are first names, because a human types them. "Nora & Sam" is one
  290. * row owned by two, and it is here so the shared-owner split stays tested.
  291. * - Statuses include the ones with meaning and a blank. Live rows can be
  292. * blank; a fixture with none would let the unclassified path go untested
  293. * (rule 2).
  294. * - `Ready to Launch` is present, because it is the judgement call in
  295. * config/content-tracker.ts and the one a reader may want to overturn.
  296. * - Titles carry commas and a URL, because the CSV parser has to survive both.
  297. */
  298. const sheetTask = (
  299. id: string,
  300. tab: string,
  301. title: string,
  302. owners: string[],
  303. status: string,
  304. opts: { notes?: string; deadline?: string; sku?: string } = {},
  305. ): TrackerTask => ({
  306. id,
  307. tab,
  308. title,
  309. owners,
  310. status,
  311. notes: opts.notes ?? '',
  312. deadline: opts.deadline ?? null,
  313. sku: opts.sku ?? null,
  314. shared: owners.length > 1,
  315. })
  316. const SHEET_TASKS: TrackerTask[] = [
  317. sheetTask('0:75', 'Images', 'Backdrop: https://meridiandisplay.example/10ft-fabric-backdrop-(straight)/', ['Theo'], 'In Progress', { notes: 'Send to the retoucher 08/04/2026' }),
  318. sheetTask('0:78', 'Images', 'Canopy Tent Product Images 20x20ft', ['Marco'], 'In Progress', { sku: 'CT20', deadline: '08/21/2026' }),
  319. sheetTask('0:79', 'Images', 'Canopy Tent Product Images 10x10ft', ['Rafael'], 'In Progress', { sku: 'CT10', deadline: '08/21/2026' }),
  320. sheetTask('0:81', 'Images', 'Redraw the site icons in a simpler style', ['Ines'], 'In Progress'),
  321. sheetTask('0:82', 'Images', 'Fabric sample photos: custom size printed graphics', ['Sam'], 'Not Started'),
  322. sheetTask('0:83', 'Images', 'Reuse/adapt existing table throw images (2 rows), fix large throw profile image', ['Marco'], 'Not Started', { sku: 'TT8', deadline: '08/28/2026' }),
  323. sheetTask('0:85', 'Images', 'Options image for flags and banners on the product page', ['Rafael'], 'In Adjustments', { deadline: '08/28/2026' }),
  324. // Finished by them, waiting on the launch step. Context, never workload.
  325. sheetTask('0:71', 'Images', 'New image for the feather flag back view', ['Sam'], 'Ready to Launch'),
  326. sheetTask('0:73', 'Images', 'book a design consultation image', ['Marco'], 'Ready to Launch', { deadline: '08/21/2026' }),
  327. sheetTask('0:74', 'Images', 'Create 10ft image for canopy tents', ['Rafael'], 'Ready to Launch'),
  328. // Blank status. Unclassified, counted, and named on the page, not defaulted.
  329. sheetTask('0:86', 'Images', 'Mesh banner in-use photo improvement', ['Rafael'], ''),
  330. sheetTask('0:88', 'Images', 'Improve the fabric banner photo to show real texture', ['Marco'], ''),
  331. // Two owners on one row.
  332. sheetTask('100000001:5', 'Video & Photo', 'Photo shoot for every feather flag size', ['Nora', 'Sam'], 'In Progress'),
  333. // Done.
  334. sheetTask('100000002:2', 'Copy & SEO', 'Add FAQs to the canopy tent page', ['Ines'], 'Launched'),
  335. sheetTask('100000003:2', 'Newsletter', 'Create newsletter outline + main image', ['Marco'], 'Launched'),
  336. ]
  337. /**
  338. * Fixture meetings, with attendee lists, derived from the frozen Fathom seed.
  339. *
  340. * The participants are synthesised because the seed records action items and
  341. * their assignees but not who was in the room. A fixture with no attendees
  342. * would make every meeting visible to nobody, and the gate would pass its
  343. * tests by rendering nothing at all: a fixture that agrees with a broken gate.
  344. *
  345. * So each meeting's attendees are its item owners, and one meeting is
  346. * deliberately restricted to a subset of the roster: the leadership case is the
  347. * whole reason the gate exists, so `pnpm dashboard --fixtures` has to contain a
  348. * meeting that most viewers must not see.
  349. */
  350. export const FIXTURE_MEETINGS: MeetingRecord[] = FATHOM_SEED.slice(0, 8).map((m, i) => {
  351. const owners: string[] = []
  352. const items = m.actionItems.map((item) => {
  353. const a = attributeFathomAssignee(item.assignee)
  354. const owner = a.kind === 'roster' ? a.person.name : null
  355. if (owner && !owners.includes(owner)) owners.push(owner)
  356. return {
  357. text: item.text,
  358. owner,
  359. externalOwner: a.kind === 'external' ? a.label : null,
  360. unknownOwner: a.kind === 'unknown' ? a.label : null,
  361. playbackUrl: `${m.url}?timestamp=60`,
  362. }
  363. })
  364. // Meeting 0 is the leadership case: Sam and Nora only, whatever its items
  365. // say. Every gating test in the suite leans on this one.
  366. const participants = i === 0 ? ['Sam Whitaker', 'Nora Lindqvist'] : owners
  367. return {
  368. recordingId: m.recordingId,
  369. title: i === 0 ? 'Leadership only: comp and headcount' : m.title,
  370. // Recent, not the seed's frozen date. Commitments are windowed to
  371. // COMMITMENT_WINDOW_DAYS, so seed-dated meetings fall outside it and the
  372. // fixture page renders an empty card, which would also let every gating
  373. // test pass by showing nothing at all. Days 1..8 keep all eight inside.
  374. date: new Date(Date.now() - (i + 1) * 86_400_000).toISOString(),
  375. url: m.url,
  376. participants,
  377. externals: [],
  378. unmatchedParticipants: [],
  379. items,
  380. }
  381. })
  382. export const FIXTURE_SNAPSHOT: Snapshot = {
  383. syncedAt: new Date().toISOString(),
  384. mode: 'fixtures',
  385. fixtureCapturedAt: SEED_EXPORTED_AT,
  386. leads: { ok: true, data: LEADS, error: null },
  387. orderLines: { ok: true, data: LINES, error: null },
  388. tasks: { ok: true, data: TASKS, error: null },
  389. ninety: { ok: true, data: TODOS, error: null },
  390. ninetySpread: assignmentSpread(TODOS),
  391. sheet: { ok: true, data: SHEET_TASKS, error: null },
  392. sheetTabs: {
  393. ok: [
  394. { label: 'Images', gid: '0', rows: 12 },
  395. { label: 'Video & Photo', gid: '100000001', rows: 1 },
  396. { label: 'Copy & SEO', gid: '100000002', rows: 1 },
  397. { label: 'Newsletter', gid: '100000003', rows: 1 },
  398. ],
  399. failed: [],
  400. },
  401. // Invented leads, so no window applies and nothing was truncated.
  402. leadWindow: { truncated: false, pages: 1, sinceDays: null },
  403. // A complete sweep, so the fixture page shows no truncation caveat. `lines` is
  404. // left empty deliberately: PLINTH_SEED below is the fixture's line data, and two
  405. // copies of it would drift.
  406. //
  407. // Coverage must read full, though: the seed carries an assignment for every
  408. // line, so zero here would fire the "fields stopped arriving" guard over sample
  409. // data and hang a failure pill on a perfectly healthy fixture run.
  410. orderWindow: {
  411. lines: [],
  412. truncated: false,
  413. pages: 1,
  414. assignmentFields: { production: LINES.length, accountOwner: LINES.length, total: LINES.length },
  415. },
  416. assignmentWarnings: [],
  417. // Fixture meetings carry participants so the gate is exercised with no
  418. // credentials: FIXTURE_MEETINGS below gives each seed meeting an attendee
  419. // list, and a fixture render must still withhold a meeting from someone who
  420. // was not in it. A fixture that skipped the gate would let a leak reach
  421. // `pnpm dashboard --fixtures` unnoticed.
  422. meetings: { ok: true, data: FIXTURE_MEETINGS, error: null },
  423. fathomConfigured: true,
  424. // Three keys, all clean: the shape of a good live run, so a fixture render
  425. // shows the same source pill a real one does. The partial and all-dark cases
  426. // are built by spreading over this in the tests rather than shipped here: a
  427. // fixture that is permanently degraded trains everyone to ignore the pill.
  428. // `returned` sums above FIXTURE_MEETINGS.length on purpose: two keys can
  429. // return the same meeting and the dedupe happens after the count.
  430. fathomSources: [
  431. { owner: 'Sam Whitaker', ok: true, error: null, returned: 8, truncated: false },
  432. { owner: 'Ines Carvalho', ok: true, error: null, returned: 3, truncated: false },
  433. { owner: 'Theo Bassett', ok: true, error: null, returned: 2, truncated: false },
  434. ],
  435. }