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

briefing-payload.ts

Language TypeScript28.6 KB704 lines

apps/dashboard/src/briefing-payload.ts

  1. /**
  2. * The model-facing contract.
  3. *
  4. * Everything upstream of here (three API clients, the stage
  5. * taxonomies, the review-gate arithmetic, the assignment join) is deterministic
  6. * TypeScript and costs zero model tokens. This file is the seam where that work
  7. * gets handed to something that reasons. It emits one small, versioned JSON
  8. * object per run. Whatever generates the analysis (a local model, a hosted one,
  9. * a Python service, a person reading it) consumes this and nothing else.
  10. *
  11. * Why that split matters, in the only terms that count here: a model asked to
  12. * reason over raw Bitrix and Plinth payloads reads 200k-500k tokens per run. A
  13. * model reading this reads a few thousand. Same conclusions, ~1% of the budget.
  14. * The distillation is the point. If you find yourself widening this payload to
  15. * "let the model decide", stop: that is the aggregation leaking back across the
  16. * seam, and it is exactly what makes a daily job unaffordable.
  17. *
  18. * The rules block is not documentation. `INTERPRETATION_RULES` ships inside the
  19. * payload, and it is load-bearing. Meridian's data has four traps that produce
  20. * confidently wrong sentences if a model does not know about them, and the worst
  21. * of those sentences get read aloud in front of the team. A model that has the
  22. * numbers but not the rules is more dangerous than one with no data at all,
  23. * because it is fluent.
  24. *
  25. * Null is not zero, anywhere in here. A dead source and an empty queue are the
  26. * same shape at a glance and opposite in meaning. Every optional number in this
  27. * payload is `null` when unknown, and every null has a matching entry in
  28. * `sources` or `coverage` saying why.
  29. */
  30. import { classifyOrder, type OrderLine } from '@meridian/plinth-client'
  31. import { partitionTasks } from '@meridian/bitrix-client'
  32. import { assignmentGroups, isActuallyLate, reviewGatesMissed, localDay, MERIDIAN_TZ, type Todo } from '@meridian/ninety-client'
  33. import {
  34. MERIDIAN_PEOPLE,
  35. SOURCE_LABELS,
  36. personFromNinety,
  37. unmappedNinetyOwners,
  38. type Person,
  39. } from '../../../config/meridian-people.js'
  40. import { isRestrictedTeam } from '../../../config/ninety-teams.js'
  41. import {
  42. headline,
  43. orderQueue,
  44. todoLoad,
  45. type Headline,
  46. type OrderQueueRow,
  47. type Snapshot,
  48. } from './collect.js'
  49. /**
  50. * Bump the minor when adding a field, the major when changing or removing one.
  51. * A consumer that pins a major and reads `schemaVersion` can fail loudly instead
  52. * of silently summarizing fields that moved.
  53. */
  54. export const PAYLOAD_SCHEMA_VERSION = '1.1.0'
  55. /**
  56. * How many rows of each list to include.
  57. *
  58. * These are budget decisions, not display decisions. The lists are sorted with
  59. * the most urgent first, so a cap truncates the tail rather than the signal, and
  60. * `truncated` tells the consumer it happened so it never says "all" about a
  61. * partial list.
  62. */
  63. export const LIST_CAP = 25
  64. /**
  65. * The four things a model must know before it writes a sentence about Meridian.
  66. *
  67. * Every one of these was learned from live data and every one of them, if
  68. * ignored, produces a fluent and wrong statement.
  69. */
  70. export const INTERPRETATION_RULES: string[] = [
  71. 'An open to-do does NOT mean unfinished work. Meridian policy is that nothing is ' +
  72. 'marked complete outside a Monday or Friday review, so finished work stays ' +
  73. 'open until the next review signs it off. Never write that someone is ' +
  74. '"behind" or "has not done" something purely because a to-do is open. Use ' +
  75. 'the `late` count, which already accounts for review gates, and say "past ' +
  76. 'due and past a review" rather than "overdue".',
  77. 'Commitments and assignments are different numbers and both are given. One ' +
  78. 'commitment can be carried by several people: on the day this was built, ' +
  79. '36 rows represented 27 distinct commitments, some shared by three people. ' +
  80. 'Report commitments when describing how much work exists, and assignments ' +
  81. 'only when describing individual load. Never call the assignment count a ' +
  82. 'number of tasks.',
  83. 'Order status and product status disagree, and NEITHER is simply the true ' +
  84. 'one. Orders read "On Press" while their artwork is still in review; ' +
  85. 'product rows park at their last value and still read "File Check" on ' +
  86. 'orders that have already shipped. Every status here is the product-level ' +
  87. 'one, and a line is only counted as work when its order has also not moved ' +
  88. 'past Part Dispatched. Do not reconcile a status against what somebody ' +
  89. 'quotes from an order screen: they are reading the other field.',
  90. 'Waiting-on-client and at-vendor are NOT the team being behind. They are ' +
  91. 'counted separately on purpose. Only `ourMove` is work this team owes. ' +
  92. 'Never sum them into a backlog figure or describe the team as behind on ' +
  93. 'the basis of them.',
  94. 'A null means the source cannot see it, not that the value is zero. Check ' +
  95. '`sources` and each person\'s `coverage` before saying anyone has "nothing" ' +
  96. 'or is "free". If a number is null, say it is not tracked.',
  97. 'Order numbers are given in the customer-facing form (MD2639582) and are safe ' +
  98. 'to quote verbatim. Never reformat one, and never invent one.',
  99. ]
  100. /**
  101. * The leadership view: what the whole company is carrying, and what these
  102. * numbers genuinely cannot tell you about it.
  103. *
  104. * A separate rule set, not a tone choice.
  105. *
  106. * A bird's-eye board invites one specific wrong inference: that a long-running
  107. * item means somebody was slow. Every source here makes that inference
  108. * unsupportable, and not as a matter of charity:
  109. *
  110. * - Plinth records no status-change timestamps. `ageDays` is time since the order
  111. * was placed. A 30-day line may be 29 days waiting on a client's reply, or
  112. * two days of work that started yesterday. The data cannot tell them apart.
  113. * - Nothing anywhere records completion time per person, hours worked,
  114. * interruptions, or how long a client took to respond. Throughput is not a
  115. * quantity this system can compute.
  116. * - Ninety to-dos may only be closed at a Monday or Friday review, so an open
  117. * to-do can be finished work waiting for sign-off.
  118. * - Assignment arrives on the Plinth response itself, so it is as fresh as the
  119. * sync, but a line Plinth marks `--` is unassigned, not unknown.
  120. *
  121. * So the honest leadership number is concurrency (how much one person is
  122. * holding at once), not throughput. Concurrency is measurable and it is the
  123. * thing that actually predicts whether a small team drops something.
  124. *
  125. * `unmeasurable` ships in the payload for the same reason `rules` does: a model
  126. * given a per-person table and no constraints will rank people, and a ranking
  127. * this data cannot support is worse than no briefing.
  128. */
  129. export interface ExecView {
  130. teamSize: number
  131. /** Work the team owes the next move on. The only figure that is team load. */
  132. ourMove: number
  133. /** Sitting with the client. Explicitly not team load. */
  134. waitingOnClient: number
  135. /** Sitting with a vendor. Explicitly not team load. */
  136. atVendor: number
  137. /** Distinct commitments, and the rows they spread across. */
  138. commitments: number | null
  139. assignments: number | null
  140. /**
  141. * Order lines carrying both a designer and a CS rep. Every one is a handoff,
  142. * and handoffs are where a small team loses time to coordination rather than
  143. * to effort.
  144. */
  145. handoffPoints: number
  146. /** Work areas exactly one person covers. Concentration risk, not performance. */
  147. soleCoverage: { area: string; person: string }[]
  148. /** Per person: what they are holding. Never a rate, never a ranking. */
  149. load: {
  150. name: string
  151. role: string
  152. /** Order lines where they owe the next move. */
  153. holding: number | null
  154. /** Their lines that are parked with a client or vendor: not their move. */
  155. parked: number | null
  156. /** Commitments they carry, including ones shared with others. */
  157. commitments: number | null
  158. /** Commitments they share, i.e. cannot finish alone. */
  159. shared: number
  160. /** Lines requiring a second person before they can close. */
  161. handoffs: number | null
  162. }[]
  163. /** What this data cannot show. Ships with the payload; do not omit. */
  164. unmeasurable: string[]
  165. }
  166. /** Rules for the leadership view, added to the standing ones. */
  167. export const EXEC_RULES: string[] = [
  168. 'This is a capacity view, not a performance view. The underlying systems ' +
  169. 'record no status-change timestamps, no completion times, no hours worked, ' +
  170. 'and no client response latency. You therefore cannot say anyone was slow, ' +
  171. 'fast, behind, ahead, productive, or unproductive, and you must not imply ' +
  172. 'it. If asked to, say the data does not support it.',
  173. 'AGE IS NOT TIME ON SOMEONE\'S DESK. `ageDays` counts from the day the order ' +
  174. 'was placed. A line thirty days old may have spent twenty-nine of them ' +
  175. 'waiting on a client reply. Never attribute an age to a person, and never ' +
  176. 'describe an old item as neglected.',
  177. 'Do not rank or compare people. Different roles carry different work, ' +
  178. 'assignment is a routing default in some systems, and the counts are not ' +
  179. 'commensurable. Report what each person is holding; draw no league table.',
  180. 'Report CONCURRENCY, not throughput. How much someone is holding at once is ' +
  181. 'measurable and is what predicts a small team dropping something. How fast ' +
  182. 'they work is not in this data at all.',
  183. 'When something looks stuck, the supported explanations are structural: it is ' +
  184. 'waiting on a client or vendor, it needs a second person before it can ' +
  185. 'close (see `handoffPoints`), or one person is the only cover for that area ' +
  186. '(see `soleCoverage`). Name the structural cause. Do not reach past it to a ' +
  187. 'person.',
  188. 'Team size is context for every number here. Read the counts against ' +
  189. '`teamSize` before describing anything as a lot or a little.',
  190. ]
  191. // --- shape ------------------------------------------------------------------
  192. export interface PayloadSource {
  193. name: string
  194. ok: boolean
  195. covers: string
  196. error: string | null
  197. }
  198. export interface PayloadOrder {
  199. orderNumber: string
  200. /** Null means the export carried no product name. Say "not recorded", not "". */
  201. product: string | null
  202. quantity: number
  203. status: string
  204. designer: string | null
  205. accountOwner: string | null
  206. company: string | null
  207. ageDays: number | null
  208. inDesignQueue: boolean
  209. }
  210. export interface PayloadTodo {
  211. title: string
  212. /** As Ninety spells it. What a person recognises. */
  213. owner: string
  214. /**
  215. * Canonical roster name, or null when this owner matches nobody on the roster.
  216. * Null is a data problem, not an empty value: a to-do behind an unmapped
  217. * owner is missing from every per-person view, and missing looks like done.
  218. */
  219. ownerKey: string | null
  220. dueDate: string | null
  221. /** Past due and past at least one review gate. The number worth acting on. */
  222. late: boolean
  223. reviewsMissed: number
  224. /** Other people carrying the same commitment. Empty for solo work. */
  225. sharedWith: string[]
  226. /** Set when the to-do hangs off a rock milestone. */
  227. linkedToRock: boolean
  228. /**
  229. * Readable only by the leadership-cleared readers. See config/ninety-teams.ts;
  230. * an untagged to-do counts as restricted, because an item with no team could be
  231. * anything.
  232. *
  233. * This field is the only thing separating the two company briefs.
  234. * `brief.py`'s `without_restricted()` drops every row carrying it before the
  235. * all-hands prompt is built, because prose cannot be redacted once a model has
  236. * written it. If it were missing, the filter would run, match nothing, and
  237. * fail open on a confidentiality boundary, invisibly from either side.
  238. */
  239. restricted: boolean
  240. }
  241. export interface PayloadPerson {
  242. name: string
  243. role: string
  244. /** Order lines where this person owes the next move. Null when uncovered. */
  245. ordersOwed: number | null
  246. /** Open Bitrix tasks. Null when uncovered. */
  247. tasksOpen: number | null
  248. /** Ninety to-dos assigned to them. Null when the source failed. */
  249. todosOpen: number | null
  250. todosLate: number | null
  251. /** Their oldest outstanding item, in days. Null when nothing or uncovered. */
  252. oldestDays: number | null
  253. /** Which systems can see this person at all. */
  254. coverage: string[]
  255. /** True when no source covers them. Must never be presented as "no work". */
  256. untracked: boolean
  257. /** Their own orders, capped. Empty array is real; null would mean uncovered. */
  258. orders: PayloadOrder[]
  259. todos: PayloadTodo[]
  260. /**
  261. * Meetings this person attended, with the action items from them. Null when
  262. * Fathom is not configured or the fetch failed, never [] for that, because
  263. * "no meetings" and "not tracked" lead to different briefings.
  264. *
  265. * Nothing meeting-derived appears outside this field. The company and exec
  266. * slices carry none, because the briefings written from them are read by
  267. * people who were not in the room. Gating the rendered page would do nothing
  268. * about a model handed a leadership meeting and asked to summarise the week.
  269. */
  270. meetings:
  271. | {
  272. title: string
  273. date: string
  274. items: { text: string; owner: string | null; unowned: boolean }[]
  275. }[]
  276. | null
  277. }
  278. export interface BriefingPayload {
  279. schemaVersion: string
  280. generatedAt: string
  281. /**
  282. * 'fixtures' means this payload describes a frozen snapshot, not today.
  283. *
  284. * A model handed fixture data with no marker writes a confident briefing about
  285. * orders that shipped two weeks ago and names people as holding work they
  286. * finished. The rules array gains an explicit instruction in this mode, and
  287. * `analysis/brief.py` refuses to run without --allow-fixtures.
  288. */
  289. mode: 'live' | 'fixtures'
  290. /** When frozen data was captured. Null on a live run. */
  291. capturedAt: string | null
  292. /** Calendar day in Meridian's timezone. Group and compare on this, not on UTC. */
  293. day: string
  294. timezone: string
  295. rules: string[]
  296. sources: PayloadSource[]
  297. company: {
  298. ourMove: number
  299. designQueue: number
  300. clientReplied: number
  301. waitingOnClient: number
  302. atVendor: number
  303. /** Still in play: not noise, not terminal. */
  304. openLeads: number
  305. automatedLeadsExcluded: number
  306. /**
  307. * Leads excluded as finished, not as noise. Reported so a briefing can say
  308. * "45 open, 270 closed this period" rather than implying the pipeline
  309. * shrank.
  310. */
  311. completedLeadsExcluded: number
  312. commitments: number | null
  313. assignments: number | null
  314. sharedCommitments: number | null
  315. lateTodos: number | null
  316. }
  317. people: PayloadPerson[]
  318. /** The leadership view. Capacity and structure only; see ExecView. */
  319. exec: ExecView
  320. /**
  321. * Extra rules a leadership briefing must follow, on top of `rules`.
  322. * Shipped separately so the exec consumer can put them first: they are the
  323. * constraining ones, and a model running short on attention should lose style
  324. * guidance rather than limits.
  325. */
  326. execRules: string[]
  327. queues: {
  328. /** Oldest first. The top of this is what is actually rotting. */
  329. oldestOrders: PayloadOrder[]
  330. unassignedOrders: PayloadOrder[]
  331. lateTodos: PayloadTodo[]
  332. truncated: boolean
  333. }
  334. dataHealth: {
  335. warnings: string[]
  336. unknownProductStatuses: string[]
  337. /** Order lines with no designer. Not a queue: a gap in the export. */
  338. ordersMissingDesigner: number
  339. /**
  340. * Ninety owners matching nobody on the roster. Their to-dos are counted in
  341. * the company totals but appear on no per-person view, so this being
  342. * non-empty means someone's work is invisible. Add them to MERIDIAN_PEOPLE with
  343. * their `ninetyId` from `pnpm roster`, or declare them in
  344. * KNOWN_NON_ROSTER_NINETY if they are not staff. Do not reach for `aliases`:
  345. * matching on a display name is how people end up resolving to nobody.
  346. */
  347. unmappedTodoOwners: { id: string; name: string }[]
  348. /**
  349. * How many fetched lines carried each Plinth assignment field
  350. * (production_owner, account_owner). They arrive on the API with every
  351. * sync, so the question is whether they arrived, not how old they are.
  352. */
  353. assignmentFieldCoverage: { production: number; accountOwner: number; total: number }
  354. junkTasksExcluded: number | null
  355. }
  356. }
  357. // --- build ------------------------------------------------------------------
  358. function toPayloadOrder(r: OrderQueueRow): PayloadOrder {
  359. return {
  360. orderNumber: r.orderNumber,
  361. product: r.product,
  362. quantity: r.quantity,
  363. status: r.status,
  364. designer: r.designer,
  365. accountOwner: r.accountOwner,
  366. company: r.company,
  367. ageDays: r.ageDays,
  368. inDesignQueue: r.designQueue,
  369. }
  370. }
  371. function toPayloadTodos(todos: Todo[], now: Date): PayloadTodo[] {
  372. // Shared-commitment membership, so a briefing can say "with Marco and Ines"
  373. // instead of silently repeating the same item on three people's lists as if
  374. // it were three separate jobs.
  375. const sharedBy = new Map<string, string[]>()
  376. for (const g of assignmentGroups(todos)) {
  377. if (!g.shared) continue
  378. for (const t of g.todos) {
  379. sharedBy.set(
  380. t.id,
  381. g.owners.filter((o) => o.id !== t.owner.id).map((o) => o.name),
  382. )
  383. }
  384. }
  385. return todos.map((t) => ({
  386. title: t.title,
  387. owner: t.owner.name,
  388. ownerKey: personFromNinety(t.owner.id, t.owner.name)?.name ?? null,
  389. dueDate: t.dueDateLocal,
  390. late: isActuallyLate(t.dueDate, now),
  391. reviewsMissed: reviewGatesMissed(t.dueDate, now),
  392. sharedWith: sharedBy.get(t.id) ?? [],
  393. linkedToRock: t.milestoneId !== null,
  394. restricted: isRestrictedTeam(t.teamId, t.teamName),
  395. }))
  396. }
  397. /**
  398. * What the data cannot show. Stated positively, as facts about the systems.
  399. *
  400. * These are not hedges. Each one is a documented limitation with a source, and
  401. * together they are the reason a performance reading is unavailable rather than
  402. * merely discouraged.
  403. */
  404. export const UNMEASURABLE: string[] = [
  405. 'Time in status. Plinth records no status-change timestamps, so an order line\'s ' +
  406. 'age counts from the day it was placed, not from when it reached its ' +
  407. 'current step, and not from when anyone picked it up.',
  408. 'Time spent waiting on a client. Client reply latency is not recorded ' +
  409. 'anywhere, so a long-running line cannot be separated into work time and ' +
  410. 'wait time.',
  411. 'Completion time, throughput, or effort per person. No system records when a ' +
  412. 'person finished a thing, how long it took, or how many times they were ' +
  413. 'interrupted.',
  414. 'Whether an open to-do is unfinished. Meridian policy closes to-dos only at ' +
  415. 'Monday and Friday reviews, so completed work stays open until sign-off.',
  416. 'Nothing about assignment freshness. Designer and CS assignment arrive on the ' +
  417. 'Plinth API response in the same sync as the orders, so they are as fresh as ' +
  418. 'generatedAt; a line reported with no assignee is one Plinth holds as unassigned.',
  419. 'Work that exists outside these four systems: phone calls, chats, walk-ups, ' +
  420. 'and anything handled without a ticket.',
  421. ]
  422. function buildExecView(snap: Snapshot, queue: OrderQueueRow[], todos: Todo[]): ExecView {
  423. const lines = snap.orderLines.data
  424. const cls = (l: OrderLine) => classifyOrder(l.orderStatus, l.orderStatusId || null)
  425. // A line needing both a designer and a CS rep cannot be closed by one person.
  426. // That is a coordination cost, and on a small team it is usually the real
  427. // constraint rather than anything about how hard people are working.
  428. const handoffPoints = queue.filter((r) => r.designer && r.accountOwner).length
  429. const designers = new Set(lines.map((l) => l.productionOwner).filter(Boolean) as string[])
  430. const cs = new Set(lines.map((l) => l.accountOwner).filter(Boolean) as string[])
  431. const soleCoverage: ExecView['soleCoverage'] = []
  432. if (designers.size === 1) soleCoverage.push({ area: 'Design / artwork', person: [...designers][0]! })
  433. if (cs.size === 1) soleCoverage.push({ area: 'Client success on orders', person: [...cs][0]! })
  434. const groups = assignmentGroups(todos)
  435. const load = MERIDIAN_PEOPLE.map((p: Person) => {
  436. const coversPlinth = p.coveredBy.some((c) => c.startsWith('plinth-'))
  437. const mine = coversPlinth
  438. ? lines.filter((l) => l.productionOwner === p.name || l.accountOwner === p.name)
  439. : []
  440. const theirTodos = todos.filter((t) => personFromNinety(t.owner.id, t.owner.name)?.name === p.name)
  441. return {
  442. name: p.name,
  443. role: p.role,
  444. holding: coversPlinth ? mine.filter((l) => cls(l) === 'ours').length : null,
  445. parked: coversPlinth ? mine.filter((l) => cls(l) === 'client' || cls(l) === 'vendor').length : null,
  446. commitments: snap.ninety.ok ? theirTodos.length : null,
  447. shared: groups.filter((g) => g.shared && g.owners.some((o) => personFromNinety(o.id, o.name)?.name === p.name)).length,
  448. handoffs: coversPlinth
  449. ? mine.filter((l) => l.productionOwner && l.accountOwner && cls(l) === 'ours').length
  450. : null,
  451. }
  452. })
  453. const h = headline(snap)
  454. return {
  455. teamSize: MERIDIAN_PEOPLE.length,
  456. ourMove: h.ourMove,
  457. waitingOnClient: h.waitingOnClient,
  458. atVendor: h.atVendor,
  459. commitments: h.commitments,
  460. assignments: h.assignments,
  461. handoffPoints,
  462. soleCoverage,
  463. load,
  464. unmeasurable: UNMEASURABLE,
  465. }
  466. }
  467. /**
  468. * The Fathom row for the payload's source list.
  469. *
  470. * `ok` is false for a partial read as well as a total one, and that is
  471. * deliberate: the question this field answers for the model is "may I describe
  472. * the meeting lists below as complete", and the answer with one key dark is no.
  473. * The error text carries the distinction a human needs: which owner, and what
  474. * the API actually said.
  475. */
  476. function fathomSource(snap: Snapshot): {
  477. name: string
  478. ok: boolean
  479. covers: string
  480. error: string | null
  481. } {
  482. const covers = 'meeting action items, gated to the people who were in the room'
  483. if (!snap.fathomConfigured) {
  484. return {
  485. name: 'Fathom meetings',
  486. ok: false,
  487. covers,
  488. // Not tracked, not empty. Without this the model reads `meetings: null`
  489. // on every person and has no way to tell "nobody was asked" from "nothing
  490. // was said": the prime invariant, arriving at a model instead of a page.
  491. error: 'No Fathom key is configured, so no meeting is on this payload. Not zero meetings.',
  492. }
  493. }
  494. if (!snap.meetings.ok) {
  495. return { name: 'Fathom meetings', ok: false, covers, error: snap.meetings.error }
  496. }
  497. const dark = snap.fathomSources.filter((k) => !k.ok)
  498. const capped = snap.fathomSources.filter((k) => k.ok && k.truncated)
  499. if (!dark.length && !capped.length) {
  500. return { name: 'Fathom meetings', ok: true, covers, error: null }
  501. }
  502. return {
  503. name: 'Fathom meetings',
  504. ok: false,
  505. covers,
  506. error:
  507. [
  508. dark.length
  509. ? `${dark.map((k) => `${k.owner}'s key failed (${k.error})`).join('; ')}.`
  510. : '',
  511. capped.length
  512. ? `${capped.map((k) => k.owner).join(', ')} hit the page cap, so that sweep is partial.`
  513. : '',
  514. `Meetings recorded by those people are missing from this payload rather than absent from ` +
  515. `the fortnight. The rest of the keys read cleanly and their meetings are here.`,
  516. ]
  517. .filter(Boolean)
  518. .join(' '),
  519. }
  520. }
  521. export function buildBriefingPayload(snap: Snapshot, now = new Date()): BriefingPayload {
  522. const h: Headline = headline(snap)
  523. const lines = snap.orderLines.data
  524. const queue = orderQueue(lines)
  525. const todos = snap.ninety.ok ? snap.ninety.data : []
  526. const allTodos = toPayloadTodos(todos, now)
  527. const loads = todoLoad(todos, now)
  528. const { real: realTasks, junk } = partitionTasks(snap.tasks.data)
  529. const unassigned = queue.filter((r) => r.designer === null)
  530. const lateTodos = allTodos.filter((t) => t.late)
  531. const people: PayloadPerson[] = MERIDIAN_PEOPLE.map((p: Person) => {
  532. const coversPlinth = p.coveredBy.some((c) => c.startsWith('plinth-'))
  533. const coversTasks = p.coveredBy.includes('bitrix-tasks')
  534. const theirOrders = coversPlinth
  535. ? queue.filter((r) => r.designer === p.name || r.accountOwner === p.name)
  536. : []
  537. const theirTasks = coversTasks
  538. ? realTasks.filter((t) => t.responsibleId === p.name)
  539. : []
  540. const load = loads.find((l) => l.name === p.name)
  541. // Resolved through the roster, not by string-matching display names.
  542. // Ninety spells "Samuel Whitaker" where the roster reads "Sam Whitaker";
  543. // an equality join would silently attribute nothing to them.
  544. const theirTodos = allTodos.filter((t) => t.ownerKey === p.name)
  545. const ages = theirOrders.map((r) => r.ageDays).filter((d): d is number => d !== null)
  546. return {
  547. name: p.name,
  548. role: p.role,
  549. ordersOwed: coversPlinth ? theirOrders.length : null,
  550. tasksOpen: coversTasks ? theirTasks.length : null,
  551. todosOpen: snap.ninety.ok ? (load?.open ?? 0) : null,
  552. todosLate: snap.ninety.ok ? (load?.late ?? 0) : null,
  553. oldestDays: ages.length ? Math.max(...ages) : null,
  554. coverage: p.coveredBy.map((c) => SOURCE_LABELS[c] ?? c),
  555. untracked: p.coveredBy.length === 0,
  556. orders: theirOrders.slice(0, LIST_CAP).map(toPayloadOrder),
  557. todos: theirTodos.slice(0, LIST_CAP),
  558. // Gated by participation, exactly as the rendered page is. See the field's
  559. // declaration for why the payload is its own leak path.
  560. meetings:
  561. snap.fathomConfigured && snap.meetings.ok
  562. ? snap.meetings.data
  563. .filter((m) => m.participants.includes(p.name))
  564. .slice(0, LIST_CAP)
  565. .map((m) => ({
  566. title: m.title,
  567. date: m.date,
  568. items: m.items.slice(0, LIST_CAP).map((i) => ({
  569. text: i.text,
  570. owner: i.owner,
  571. unowned: !i.owner && !i.externalOwner && !i.unknownOwner,
  572. })),
  573. }))
  574. : null,
  575. }
  576. })
  577. const truncated =
  578. queue.length > LIST_CAP || unassigned.length > LIST_CAP || lateTodos.length > LIST_CAP
  579. return {
  580. schemaVersion: PAYLOAD_SCHEMA_VERSION,
  581. generatedAt: snap.syncedAt,
  582. mode: snap.mode,
  583. capturedAt: snap.fixtureCapturedAt,
  584. day: localDay(snap.syncedAt, MERIDIAN_TZ) ?? snap.syncedAt.slice(0, 10),
  585. timezone: MERIDIAN_TZ,
  586. rules:
  587. snap.mode === 'fixtures'
  588. ? [
  589. `THIS IS SAMPLE DATA captured ${snap.fixtureCapturedAt?.slice(0, 10) ?? 'earlier'}, ` +
  590. `not today's work. Orders described here as open have since shipped. Do not present ` +
  591. `any of it as current, do not name anyone as holding work, and say plainly in your ` +
  592. `first sentence that this is a sample.`,
  593. ...INTERPRETATION_RULES,
  594. ]
  595. : INTERPRETATION_RULES,
  596. sources: [
  597. {
  598. name: 'Bitrix leads',
  599. ok: snap.leads.ok,
  600. covers: 'open leads and their stage',
  601. error: snap.leads.error,
  602. },
  603. {
  604. name: 'Bitrix tasks',
  605. ok: snap.tasks.ok,
  606. covers: 'internal task queue',
  607. error: snap.tasks.error,
  608. },
  609. {
  610. name: 'Plinth order lines',
  611. ok: snap.orderLines.ok,
  612. covers: 'orders, product status, designer assignment',
  613. error: snap.orderLines.error,
  614. },
  615. {
  616. name: 'Ninety to-dos',
  617. ok: snap.ninety.ok,
  618. covers: 'company-wide commitments, all people, one admin token',
  619. error: snap.ninety.error,
  620. },
  621. // Fathom belongs on this list because each person's `meetings` array is
  622. // already gated to what they attended, so a short one is normal, and
  623. // without this row nothing in the payload would tell the model the
  624. // difference between a quiet fortnight and a key that could not be read. brief.py turns an `ok: false` row into a warning naming the
  625. // source, which is how a dark key reaches a human.
  626. fathomSource(snap),
  627. ],
  628. company: {
  629. ourMove: h.ourMove,
  630. designQueue: h.designQueue,
  631. clientReplied: h.needsReply,
  632. waitingOnClient: h.waitingOnClient,
  633. atVendor: h.atVendor,
  634. openLeads: h.realLeads,
  635. automatedLeadsExcluded: h.noiseLeads,
  636. completedLeadsExcluded: h.completedLeads,
  637. commitments: h.commitments,
  638. assignments: h.assignments,
  639. sharedCommitments: h.sharedCommitments,
  640. lateTodos: h.lateTodos,
  641. },
  642. people,
  643. exec: buildExecView(snap, queue, todos),
  644. execRules: EXEC_RULES,
  645. queues: {
  646. oldestOrders: queue.slice(0, LIST_CAP).map(toPayloadOrder),
  647. unassignedOrders: unassigned.slice(0, LIST_CAP).map(toPayloadOrder),
  648. lateTodos: lateTodos.slice(0, LIST_CAP),
  649. truncated,
  650. },
  651. dataHealth: {
  652. warnings: snap.assignmentWarnings,
  653. unknownProductStatuses: h.unknownStatuses,
  654. ordersMissingDesigner: unassigned.length,
  655. unmappedTodoOwners: unmappedNinetyOwners(
  656. todos.map((t) => ({ id: t.owner.id, name: t.owner.name })),
  657. ),
  658. assignmentFieldCoverage: snap.orderWindow.assignmentFields,
  659. junkTasksExcluded: snap.tasks.ok ? junk.length : null,
  660. },
  661. }
  662. }