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

collect.ts

Language TypeScript37.4 KB936 lines

apps/dashboard/src/collect.ts

  1. /**
  2. * Pull both sources and reduce to what the page renders.
  3. *
  4. * Design rules (see docs/ARCHITECTURE.md):
  5. *
  6. * - Group by stage, never by assignee. Bitrix's assignee is a routing default;
  7. * every open lead sampled belonged to one person.
  8. * - Count product lines, not orders. Order status and product status disagree.
  9. * - Only 'ours' counts as load. Waiting-on-client is a follow-up list.
  10. * - Flag automation noise; never silently drop it.
  11. * - Report per-source failure. A source that dies must show as broken, not as
  12. * zero. A dashboard that quietly reports zero is worse than one that errors.
  13. */
  14. import {
  15. BitrixClient,
  16. LeadsResource,
  17. TasksResource,
  18. partitionTasks,
  19. type Lead,
  20. type Task,
  21. } from '@meridian/bitrix-client'
  22. import { fetchTracker, type TrackerTask } from '@meridian/sheets-client'
  23. import {
  24. TRACKER_SHEET_ID,
  25. TRACKER_TABS,
  26. classifyTrackerStatus,
  27. type TrackerClass,
  28. } from '../../../config/content-tracker.js'
  29. import { MERIDIAN_PEOPLE, personByName, personFromBitrix, personFromNinety, personFromTrackerOwner, unmappedTrackerOwners, offRosterBitrixIds, unpinnedBitrixPeople, SOURCE_LABELS, type Person } from '../../../config/meridian-people.js'
  30. import {
  31. attributeFathomRef,
  32. isUnownedRef,
  33. participantsOf,
  34. COMMITMENT_WINDOW_DAYS,
  35. } from '../../../config/fathom-people.js'
  36. import { FATHOM_KEYS, FATHOM_WINDOW_DAYS } from '../../../config/fathom-keys.js'
  37. import { FathomClient, MeetingsResource } from '@meridian/fathom-client'
  38. import {
  39. PlinthClient,
  40. OrdersResource,
  41. displayOrderNumber,
  42. isKnownOrderStatus,
  43. classifyProduct,
  44. classifyLine,
  45. isLineDesignQueue,
  46. isOrderPastFulfilment,
  47. type AssignmentFieldCoverage,
  48. type OrderLine,
  49. type OrderWindow,
  50. type WorkClass,
  51. } from '@meridian/plinth-client'
  52. import {
  53. assignmentSpread,
  54. clientFromEnv,
  55. isActuallyLate,
  56. reviewGatesMissed,
  57. type Todo,
  58. } from '@meridian/ninety-client'
  59. import { LEAD_STAGES, classify as classifyLead, stageName, unknownStageIds } from '../../../config/bitrix-stages.js'
  60. import { displayName, isUnmappedAccount } from '../../../config/plinth-people.js'
  61. import { errorMessage } from '@meridian/shared'
  62. export interface SourceResult<T> {
  63. ok: boolean
  64. data: T
  65. error: string | null
  66. }
  67. export interface Snapshot {
  68. syncedAt: string
  69. /**
  70. * Where this snapshot came from. Every status, banner and payload field reads
  71. * this first, so a fixture run can never render "live" pills over a frozen
  72. * export: a source asserting a status it has not earned is the failure this
  73. * board is built to avoid.
  74. */
  75. mode: 'live' | 'fixtures'
  76. /** When the frozen data was captured. Null for a live run. */
  77. fixtureCapturedAt: string | null
  78. leads: SourceResult<Lead[]>
  79. orderLines: SourceResult<OrderLine[]>
  80. tasks: SourceResult<Task[]>
  81. /**
  82. * Company-wide Ninety to-dos, from one admin token via GET /todos/company.
  83. *
  84. * A 403 here is about the token's Ninety role, not a bug. The error text says
  85. * so.
  86. */
  87. ninety: SourceResult<Todo[]>
  88. /**
  89. * The Content Creation tracker (Google Sheet).
  90. *
  91. * It is the only source here that is a hand-maintained document rather than a
  92. * system of record, which makes it the one most likely to go quietly stale,
  93. * and the one we can say the least about, because the public CSV endpoint
  94. * carries no last-modified. Both facts are on the page rather than in this
  95. * comment alone. See config/content-tracker.ts.
  96. */
  97. sheet: SourceResult<TrackerTask[]>
  98. /** Which tabs answered and which did not. A partial read is reported, never thrown. */
  99. sheetTabs: { ok: { label: string; gid: string; rows: number }[]; failed: { label: string; gid: string; reason: string }[] }
  100. /**
  101. * Assignments vs commitments. Never render one without the other: a real
  102. * share of rows are one commitment carried by two or three people, so a bare
  103. * row count overstates the work and hides that it is jointly owned.
  104. */
  105. ninetySpread: ReturnType<typeof assignmentSpread> | null
  106. /**
  107. * How the lead list was bounded. `sinceDays` changes what "open leads" means,
  108. * so it belongs next to the number rather than buried in a config file, and
  109. * `truncated` means the count is a floor rather than a total.
  110. */
  111. leadWindow: LeadWindow
  112. /**
  113. * How the order sweep was bounded. Same reason `leadWindow` exists: Plinth pages
  114. * by product line and reports no total, so `truncated` is the only way to know
  115. * whether these counts are the window or a slice of it.
  116. */
  117. orderWindow: OrderWindow
  118. assignmentWarnings: string[]
  119. /**
  120. * Meetings and their action items, from the Fathom keys in config.
  121. *
  122. * Every meeting the keys can see is in here, including ones most viewers must
  123. * never be shown. Nothing in this array is safe to render as-is: the gate is
  124. * `meetingsFor(snap, viewer)` in view-model.ts, and the company variant gets
  125. * none of it. That is a deliberate trade: one fetch, one snapshot, filtered
  126. * per viewer at render time, the same shape as every other source here.
  127. */
  128. meetings: SourceResult<MeetingRecord[]>
  129. /**
  130. * False when no Fathom key is configured. Then `meetings` is empty because
  131. * nobody was asked, which must read as "not tracked" and never as "no meetings".
  132. * An empty meeting list and an unconfigured source look identical otherwise,
  133. * and one of them means free capacity.
  134. */
  135. fathomConfigured: boolean
  136. /**
  137. * One row per key that was actually asked, in the order they were asked.
  138. *
  139. * A list and not a boolean because `meetings` is one SourceResult covering
  140. * several fetches, so on its own it cannot distinguish "every key read
  141. * cleanly" from "one key is revoked". The second is a partial view of the
  142. * company's meetings, and the difference is invisible in the merged list: the
  143. * key owner's page would simply be short, and short is what a quiet week
  144. * looks like. Named per owner so the page can say which key is dark (rule 1).
  145. *
  146. * Empty when no key is configured at all. Then `fathomConfigured` is false
  147. * and the whole source reads as not tracked.
  148. */
  149. fathomSources: FathomSourceStatus[]
  150. }
  151. /**
  152. * How one Fathom key's own sweep went. `returned` counts what that key sent,
  153. * before the cross-key dedupe, so the numbers here sum to at least the length
  154. * of the merged list and never to less.
  155. */
  156. export interface FathomSourceStatus {
  157. /** Roster display name of the key's owner, from config/fathom-keys.ts. */
  158. owner: string
  159. ok: boolean
  160. /** The failure as reported, verbatim. Never a diagnosis. */
  161. error: string | null
  162. returned: number
  163. /** The sweep hit its page cap. What is here is real but incomplete. */
  164. truncated: boolean
  165. }
  166. /**
  167. * One meeting, reduced to what the board renders and what it gates on.
  168. *
  169. * `participants` is an access list, not decoration. It is the only thing
  170. * standing between a leadership meeting and the rest of the team, because
  171. * Fathom's own permissions cannot help here (see config/fathom-keys.ts).
  172. * Roster display names rather than Person objects so the snapshot stays
  173. * JSON-serialisable for KV.
  174. */
  175. export interface MeetingRecord {
  176. recordingId: number
  177. title: string
  178. date: string
  179. url: string
  180. /** Roster names of people who were in the room: who may see this meeting. */
  181. participants: string[]
  182. /** Outsiders, named for context. Never viewers. */
  183. externals: string[]
  184. /** Internal addresses matching nobody. A question for a human, not a viewer. */
  185. unmatchedParticipants: string[]
  186. items: MeetingItem[]
  187. }
  188. export interface MeetingItem {
  189. text: string
  190. /** Roster name. Null does not mean unowned; check the other two first. */
  191. owner: string | null
  192. /** External label when an outsider owns it; null otherwise. */
  193. externalOwner: string | null
  194. /**
  195. * A name Fathom gave that the board cannot resolve.
  196. *
  197. * Distinct from unowned, and collapsing the two would lose the difference
  198. * between "nobody was named" and "somebody was named and we do not know who".
  199. * The first is work looking for an owner; the second is a config gap. All
  200. * three owner fields null means genuinely unowned.
  201. */
  202. unknownOwner: string | null
  203. /** Deep link to the moment it was said. */
  204. playbackUrl: string | null
  205. }
  206. export interface LeadWindow {
  207. /** True when the page cap was hit: the count is a floor, not a total. */
  208. truncated: boolean
  209. pages: number
  210. /** Days of history included. Null means no floor: everything ever opened. */
  211. sinceDays: number | null
  212. }
  213. export interface StageCount {
  214. id: string
  215. name: string
  216. count: number
  217. class: WorkClass | 'ours' | 'waiting' | 'vendor' | 'parked' | 'terminal'
  218. }
  219. export async function collect(
  220. env: Record<string, string | undefined>,
  221. days = 60,
  222. ): Promise<Snapshot> {
  223. const syncedAt = new Date().toISOString()
  224. // Windowed to the same span as the Plinth pull, so the board describes one period
  225. // rather than two. Without a floor this returns thousands of rows: the email-to-lead
  226. // automation makes a lead out of every security alert, call log and chat, and
  227. // nothing closes them. A lead untouched for eighteen months is debt, not
  228. // pipeline, and counting it as pipeline makes every other number meaningless.
  229. let leadWindow: LeadWindow = { truncated: false, pages: 0, sinceDays: days }
  230. const leads = await safely(async () => {
  231. const http = new BitrixClient({ webhookUrl: env.BITRIX_WEBHOOK_URL ?? '', debug: env.DEBUG === 'true' })
  232. const res = await new LeadsResource(http).listOpen({ sinceDays: days })
  233. leadWindow = { truncated: res.truncated, pages: res.pages, sinceDays: res.sinceDays }
  234. return res.leads
  235. }, [] as Lead[])
  236. // Declared outside `safely` for the same reason leadWindow is: a failed sweep
  237. // must leave the window describing a failure, not asserting a clean one.
  238. let orderWindow: OrderWindow = {
  239. lines: [],
  240. truncated: false,
  241. pages: 0,
  242. // total 0 keeps the coverage guard silent when the Plinth fetch failed: that
  243. // failure is already reported as failed, and reporting it a second time as a
  244. // coverage gap would describe one outage as two problems.
  245. assignmentFields: { production: 0, accountOwner: 0, total: 0 },
  246. }
  247. const orderLines = await safely(async () => {
  248. const client = new PlinthClient({ token: env.PLINTH_API_KEY ?? '', debug: env.DEBUG === 'true' })
  249. const to = new Date()
  250. const from = new Date(to.getTime() - days * 86_400_000)
  251. orderWindow = await new OrdersResource(client).linesInWindow(iso(from), iso(to))
  252. return orderWindow.lines
  253. }, [] as OrderLine[])
  254. const tasks = await safely(async () => {
  255. const http = new BitrixClient({ webhookUrl: env.BITRIX_WEBHOOK_URL ?? '', debug: env.DEBUG === 'true' })
  256. return new TasksResource(http).listOpen()
  257. }, [] as Task[])
  258. // Hydrated, because the thin index carries no seriesId, milestoneId or
  259. // dueDateChanged, and without those you cannot collapse recurrence, link a
  260. // to-do to its rock, or spot a due date that has moved four times. ~2s for 40
  261. // rows, which is a sync cost, not a page-load cost.
  262. const ninety = await safely(async () => {
  263. const client = clientFromEnv(env)
  264. const snap = await client.companySnapshot({ detailed: true })
  265. return snap.todos
  266. }, [] as Todo[])
  267. // No credential, by design: the tracker is read from Google's public CSV
  268. // endpoint because the document is shared by link. That is what lets the
  269. // Worker cron read it at all, and it is stated in config/content-tracker.ts
  270. // because it is also a fact about who else can read it.
  271. const sheetFetch = await safely(
  272. () => fetchTracker(TRACKER_SHEET_ID, TRACKER_TABS),
  273. { tasks: [] as TrackerTask[], tabs: [], failedTabs: [] } as Awaited<ReturnType<typeof fetchTracker>>,
  274. )
  275. // Meetings: one fetch per configured key. Keys buy coverage, not gating: each
  276. // returns its owner's recordings plus whatever Fathom shares with that user,
  277. // by a mechanism the API does not expose. The union is deduplicated by
  278. // recording id below. See config/fathom-keys.ts.
  279. //
  280. // Everything the keys can see lands in the snapshot, including meetings most
  281. // viewers must never be shown. The gate is at render time, per viewer.
  282. const fathomKeys = FATHOM_KEYS.map((k) => ({ ...k, key: env[k.secret] })).filter(
  283. (k): k is typeof k & { key: string } => Boolean(k.key),
  284. )
  285. const fathomConfigured = fathomKeys.length > 0
  286. // One key's failure must not erase the others, so each key is caught on its
  287. // own. A single try around the loop would blank meetings for everyone the
  288. // first time one key is revoked, and name no cause.
  289. //
  290. // The other half of that trade is the prime invariant. A swallowed per-key
  291. // failure would make a key that cannot reach someone's meetings look exactly
  292. // like a person who recorded none, and one of those means free capacity. So
  293. // a dark key is never silent: it rides in `fathomSources`, the source pill reads
  294. // `partial`, and every surface that lists commitments says a key is dark.
  295. const fathomSources: FathomSourceStatus[] = []
  296. const meetings: SourceResult<MeetingRecord[]> = fathomConfigured
  297. ? await (async () => {
  298. const since = new Date(Date.now() - FATHOM_WINDOW_DAYS * 86_400_000).toISOString()
  299. const byId = new Map<number, MeetingRecord>()
  300. for (const { owner, key } of fathomKeys) {
  301. try {
  302. const window = await new MeetingsResource(
  303. new FathomClient({ apiKey: key, owner }),
  304. ).listSince(since)
  305. for (const m of window.meetings) {
  306. // Two keys can return the same meeting. Keyed by recording id so it
  307. // is stored once; the participant list is a property of the meeting,
  308. // not of whoever fetched it.
  309. if (byId.has(m.recording_id)) continue
  310. const people = participantsOf(m.calendar_invitees)
  311. byId.set(m.recording_id, {
  312. recordingId: m.recording_id,
  313. title: m.title,
  314. date: m.recording_start_time ?? m.created_at,
  315. url: m.url,
  316. participants: people.roster.map((x) => x.name),
  317. externals: people.external,
  318. unmatchedParticipants: people.unmatched,
  319. items: (m.action_items ?? []).map((item) => {
  320. const owned = isUnownedRef(item.assignee)
  321. ? null
  322. : attributeFathomRef(item.assignee, m.calendar_invitees)
  323. return {
  324. text: item.description,
  325. owner: owned?.kind === 'roster' ? owned.person.name : null,
  326. externalOwner: owned?.kind === 'external' ? owned.label : null,
  327. unknownOwner: owned?.kind === 'unknown' ? owned.label : null,
  328. playbackUrl: item.recording_playback_url ?? null,
  329. }
  330. }),
  331. })
  332. }
  333. // A capped sweep keeps what it has and says so, or the shortfall
  334. // reads as a quiet week.
  335. fathomSources.push({
  336. owner,
  337. ok: true,
  338. error: null,
  339. returned: window.meetings.length,
  340. truncated: window.truncated,
  341. })
  342. } catch (err) {
  343. fathomSources.push({
  344. owner,
  345. ok: false,
  346. error: errorMessage(err),
  347. returned: 0,
  348. truncated: false,
  349. })
  350. }
  351. }
  352. const data = [...byId.values()].sort((a, b) => Date.parse(b.date) - Date.parse(a.date))
  353. // Every key dark is a failed source, not a thin one. Keeping ok:true
  354. // there would render an empty list under a live pill, which is the exact
  355. // shape the prime invariant exists to forbid.
  356. return fathomSources.some((s) => s.ok)
  357. ? { ok: true, data, error: null }
  358. : {
  359. ok: false,
  360. data,
  361. error: fathomSources.map((s) => `${s.owner}: ${s.error}`).join('; '),
  362. }
  363. })()
  364. : // Nobody was asked. Empty because unconfigured, which `fathomConfigured`
  365. // is what distinguishes from "we looked and there are no meetings".
  366. { ok: true, data: [] as MeetingRecord[], error: null }
  367. const assignmentWarnings = orderLines.ok
  368. ? normalizeAssignments(orderLines.data, orderWindow.assignmentFields)
  369. : []
  370. return {
  371. syncedAt,
  372. mode: 'live',
  373. fixtureCapturedAt: null,
  374. leads,
  375. orderLines,
  376. tasks,
  377. ninety,
  378. ninetySpread: ninety.ok ? assignmentSpread(ninety.data) : null,
  379. sheet: { ok: sheetFetch.ok, data: sheetFetch.data.tasks, error: sheetFetch.error },
  380. sheetTabs: { ok: sheetFetch.data.tabs, failed: sheetFetch.data.failedTabs },
  381. leadWindow,
  382. orderWindow,
  383. assignmentWarnings: [...new Set(assignmentWarnings)],
  384. meetings,
  385. fathomConfigured,
  386. fathomSources,
  387. }
  388. }
  389. /**
  390. * Map raw Plinth account labels onto roster names, and judge field coverage.
  391. *
  392. * Assignments arrive on the same orders.fetch response as the orders, order
  393. * level and per product line. Two normalizations already happened in
  394. * plinth-client: the `--` sentinel became null, and a per-line value beat the
  395. * order-level one. What happens here is the roster join. peopleBoard and myDay compare
  396. * `l.productionOwner === p.name`, so a raw label ("Marco-Acct7-Desk") has to
  397. * become a name before the snapshot leaves this file, or every person reads as
  398. * holding nothing. An unmapped label is surfaced every run, never silently
  399. * rendered raw.
  400. *
  401. * The coverage guard catches assignment data that quietly stops arriving,
  402. * which otherwise looks like "nobody has much work" rather than like a break.
  403. * Zero coverage is reported as missing, never as zero.
  404. *
  405. * Mutates `lines` in place.
  406. */
  407. export function normalizeAssignments(
  408. lines: OrderLine[],
  409. coverage: AssignmentFieldCoverage,
  410. ): string[] {
  411. const warnings: string[] = []
  412. for (const line of lines) {
  413. for (const raw of [line.productionOwner, line.accountOwner]) {
  414. if (raw !== null && isUnmappedAccount(raw)) {
  415. warnings.push(
  416. `Unmapped Plinth account "${raw}". Add it to config/plinth-people.ts. ` +
  417. `Rendering a best-effort cleanup of the label until then.`,
  418. )
  419. }
  420. }
  421. line.productionOwner = displayName(line.productionOwner)
  422. line.accountOwner = displayName(line.accountOwner)
  423. }
  424. if (coverage.total > 0) {
  425. const fields = [
  426. ['production_owner', coverage.production],
  427. ['account_owner', coverage.accountOwner],
  428. ] as const
  429. for (const [field, n] of fields) {
  430. const pct = Math.round((n / coverage.total) * 100)
  431. if (n === 0) {
  432. warnings.push(
  433. `Plinth returned ${field} on 0 of ${coverage.total} fetched lines. The field arrived ` +
  434. `2026-09-02 and has stopped arriving. Per-person order load is MISSING, not zero. ` +
  435. `Ask the Plinth maintainer before reading anyone here as free.`,
  436. )
  437. } else if (pct < 50) {
  438. warnings.push(
  439. `${field} present on only ${n} of ${coverage.total} lines (${pct}%). Lines without it ` +
  440. `render unassigned, so per-person counts are a floor, not a total.`,
  441. )
  442. }
  443. }
  444. }
  445. return warnings
  446. }
  447. async function safely<T>(fn: () => Promise<T>, fallback: T): Promise<SourceResult<T>> {
  448. try {
  449. return { ok: true, data: await fn(), error: null }
  450. } catch (err) {
  451. return { ok: false, data: fallback, error: errorMessage(err) }
  452. }
  453. }
  454. const iso = (d: Date) => d.toISOString().slice(0, 10)
  455. // --- derived views ----------------------------------------------------------
  456. /**
  457. * Where the live work sits, by product stage. Feeds the stage bars.
  458. *
  459. * Product status throughout: the key, the label and the classification. The
  460. * order and product layers share status names with different ids, so mixing
  461. * them gives wrong counts and wrong colours with nothing on the chart to show
  462. * it. Lines whose order has shipped are left out, because a stage bar is a
  463. * picture of what is still in the building.
  464. */
  465. export function productStages(lines: OrderLine[]): StageCount[] {
  466. const live = lines.filter((l) => !isOrderPastFulfilment(l.orderStatus, l.orderStatusId || null))
  467. const counts = new Map<string, number>()
  468. for (const l of live) counts.set(l.productStatus, (counts.get(l.productStatus) ?? 0) + 1)
  469. return [...counts.entries()]
  470. .map(([name, count]) => ({ id: name, name, count, class: classifyProduct(name) }))
  471. .sort((a, b) => b.count - a.count)
  472. }
  473. export interface Headline {
  474. ourMove: number
  475. waitingOnClient: number
  476. atVendor: number
  477. designQueue: number
  478. /**
  479. * Leads that are genuinely still in play: not noise, and not in a terminal
  480. * stage (Junk, Reorder Completed, Converted Lead, Out of Scope, Unconverted).
  481. * Terminal is not noise; it is completed work, and on live data it can be
  482. * most of the list. Counting completed work as pipeline is the same error as
  483. * counting it as workload, one column over.
  484. *
  485. * A Converted Lead is the worst of them: it became an order, so counting it
  486. * here double-counts the same job in two systems.
  487. */
  488. realLeads: number
  489. /** Excluded as automation exhaust. Always shown; silent filtering is lying. */
  490. noiseLeads: number
  491. /** Excluded as finished. Also always shown: these are real, just not open. */
  492. completedLeads: number
  493. needsReply: number
  494. unknownStatuses: string[]
  495. /**
  496. * Ninety commitments. Null when the source failed, never 0, because 0 means
  497. * "everything is done" and a dead source means "we cannot see".
  498. */
  499. commitments: number | null
  500. /** Rows behind those commitments. Higher than `commitments` when work is shared. */
  501. assignments: number | null
  502. /** Commitments carried by more than one person. */
  503. sharedCommitments: number | null
  504. /** Open to-dos past due and past at least one review gate. */
  505. lateTodos: number | null
  506. /**
  507. * Lead stages present in the data but absent from config/bitrix-stages.ts,
  508. * with counts. Every one of these defaults to 'ours' and therefore inflates
  509. * `ourMove`. Non-empty means the headline is partly made of unclassified rows
  510. * and must not be presented as team workload without saying so.
  511. */
  512. unknownLeadStages: { id: string; count: number }[]
  513. /** How many of `ourMove`'s leads sit in an unclassified stage. */
  514. ourMoveUnclassified: number
  515. }
  516. export function headline(snap: Snapshot): Headline {
  517. const lines = snap.orderLines.data
  518. const leads = snap.leads.data
  519. const real = leads.filter((l) => !l.noise)
  520. // Product status decides, gated on the order not having shipped. See
  521. // classifyLine's note for why neither layer alone is right.
  522. const byClass = (c: string) => lines.filter((l) => classifyLine(l) === c).length
  523. const leadOurs = real.filter((l) => classifyLead(l.statusId, l.semantic) === 'ours').length
  524. // Not noise, but finished. Held separately from `real` so it can be shown
  525. // rather than silently subtracted, the same rule the noise count follows.
  526. const completed = real.filter((l) => classifyLead(l.statusId, l.semantic) === 'terminal')
  527. const stillOpen = real.filter((l) => classifyLead(l.statusId, l.semantic) !== 'terminal')
  528. return {
  529. ourMove: byClass('ours') + leadOurs,
  530. waitingOnClient: byClass('client') + real.filter((l) => classifyLead(l.statusId, l.semantic) === 'waiting').length,
  531. atVendor: byClass('vendor'),
  532. designQueue: lines.filter((l) => isLineDesignQueue(l)).length,
  533. realLeads: stillOpen.length,
  534. noiseLeads: leads.length - real.length,
  535. completedLeads: completed.length,
  536. // "Client Replied": they are waiting on us. Always surfaces first.
  537. needsReply: real.filter((l) => stageName(l.statusId) === 'Client Replied').length,
  538. unknownStatuses: [...new Set(lines.map((l) => l.orderStatus).filter((s) => !isKnownOrderStatus(s)))],
  539. commitments: snap.ninety.ok ? (snap.ninetySpread?.commitments ?? 0) : null,
  540. assignments: snap.ninety.ok ? (snap.ninetySpread?.assignments ?? 0) : null,
  541. sharedCommitments: snap.ninety.ok ? (snap.ninetySpread?.sharedCommitments ?? 0) : null,
  542. lateTodos: snap.ninety.ok
  543. ? snap.ninety.data.filter((t) => isActuallyLate(t.dueDate, new Date())).length
  544. : null,
  545. unknownLeadStages: unknownStageIds(real.map((l) => l.statusId)),
  546. ourMoveUnclassified: real.filter(
  547. (l) => classifyLead(l.statusId, l.semantic) === 'ours' && !LEAD_STAGES.some((s) => s.id === l.statusId),
  548. ).length,
  549. }
  550. }
  551. export interface PersonRow {
  552. name: string
  553. role: string
  554. /** Orders where this person owes the next move. Null when not covered. */
  555. plinthOurMove: number | null
  556. /** Orders sitting with the client. Context, not bandwidth. */
  557. plinthWaiting: number | null
  558. /** Open Bitrix tasks, junk excluded. Null when not covered. */
  559. taskCount: number | null
  560. taskOverdue: number | null
  561. /**
  562. * Open Ninety to-dos owned by this person. Null when the source failed.
  563. * Without them, people who hold no order lines read as idle on the panel
  564. * titled "workload by person".
  565. */
  566. todos: number | null
  567. /** Of those, past due and past a review gate. */
  568. todosLate: number | null
  569. /**
  570. * Content Creation tracker rows this person owes a move on. Null when the
  571. * sheet failed or does not cover them.
  572. *
  573. * This work lives nowhere else: Ninety carries one rollup row where the
  574. * sheet carries a dozen jobs, so without it the designers look far emptier
  575. * than they are.
  576. */
  577. sheetOurMove: number | null
  578. /** Finished by them and waiting on the launch step. Context, not workload. */
  579. sheetWaiting: number | null
  580. /** Oldest thing they owe, in days. */
  581. oldestDays: number | null
  582. /** Sources that cover this person, for the row annotation. */
  583. coverage: string[]
  584. coverageNote: string | null
  585. /** True when nothing can see this person. Must never render as a zero. */
  586. untracked: boolean
  587. }
  588. export interface PeopleBoard {
  589. rows: PersonRow[]
  590. /** Tasks excluded as automation exhaust. Shown, never hidden. */
  591. junkTasks: number
  592. /** Plinth accounts holding work that belong to nobody on the roster. */
  593. offRosterAccounts: string[]
  594. /**
  595. * The meeting-commitment window, in days. This board carries no meeting
  596. * content and no per-person commitment counts: it is company-visible, and
  597. * even a count ("Leadership sync: 4 items") discloses that a meeting
  598. * happened and who was left out. Meeting content renders in exactly one
  599. * place, `myDay()`, filtered by `meetingsFor()`. One renderer is a boundary
  600. * you can test.
  601. */
  602. commitmentWindowDays: number
  603. /**
  604. * The Content Creation tracker, in numbers the page can state.
  605. *
  606. * `unclassified` is separate from every other count on purpose: see the
  607. * comment on trackerCounts().
  608. */
  609. tracker: TrackerCounts
  610. /**
  611. * The Bitrix task join, as numbers instead of a claim about the method. A
  612. * description of a join can read as working while it attributes nothing; a
  613. * count cannot.
  614. *
  615. * `offRoster` is expected and not a gap: the directory holds far more accounts
  616. * than the roster, and most are already inactive in Bitrix. What it is for
  617. * is the opposite failure: a roster id going stale and that person's tasks
  618. * quietly joining this bucket, which looks exactly like having nothing to do.
  619. */
  620. taskJoin: {
  621. attributed: number
  622. real: number
  623. junk: number
  624. offRoster: { id: string; tasks: number }[]
  625. /** Roster people covered by Bitrix with no id pinned. Should be empty. */
  626. unpinned: string[]
  627. }
  628. }
  629. export interface TrackerCounts {
  630. rows: number
  631. ours: number
  632. waiting: number
  633. done: number
  634. unclassified: number
  635. unknownStatuses: string[]
  636. unmappedOwners: string[]
  637. /** Rows naming more than one owner. Counted per person, so this is context. */
  638. shared: number
  639. }
  640. /**
  641. * The tracker in one place, because two surfaces need the same numbers and a
  642. * second implementation is a second answer.
  643. *
  644. * Unclassified is not folded into anything (rule 2): a default that stands in
  645. * for knowledge must be surfaced every run. Rows with a blank or unknown
  646. * Status are counted apart and named on the page.
  647. */
  648. export function trackerCounts(snap: Snapshot): TrackerCounts {
  649. const rows = snap.sheet.data
  650. const isClass = (t: TrackerTask, c: TrackerClass | null) => classifyTrackerStatus(t.status) === c
  651. return {
  652. rows: rows.length,
  653. ours: rows.filter((t) => isClass(t, 'ours')).length,
  654. waiting: rows.filter((t) => isClass(t, 'waiting')).length,
  655. done: rows.filter((t) => isClass(t, 'terminal')).length,
  656. unclassified: rows.filter((t) => isClass(t, null)).length,
  657. unknownStatuses: [
  658. ...new Set(rows.filter((t) => isClass(t, null)).map((t) => t.status.trim() || '(blank)')),
  659. ].sort(),
  660. unmappedOwners: unmappedTrackerOwners(rows.flatMap((t) => t.owners)),
  661. shared: rows.filter((t) => t.shared).length,
  662. }
  663. }
  664. /**
  665. * The bird's-eye view: one row per person, every source folded in.
  666. *
  667. * The rule that keeps this honest: a person no source can see renders as
  668. * "not tracked", never as 0. Those look identical at a glance and only one of
  669. * them means free capacity.
  670. *
  671. * Partial coverage is normal and is annotated quietly rather than flagged.
  672. */
  673. export function peopleBoard(snap: Snapshot): PeopleBoard {
  674. const lines = snap.orderLines.data
  675. const { real: realTasks, junk } = partitionTasks(snap.tasks.data)
  676. const offRoster = new Set<string>()
  677. for (const l of lines) {
  678. for (const label of [l.productionOwner, l.accountOwner]) {
  679. if (label && !personByName(label)) offRoster.add(label)
  680. }
  681. }
  682. // Ninety is company-wide, so every roster person is covered by it.
  683. const todosByPerson = todoLoad(snap.ninety.data)
  684. // Tracker rows, split by what the Status column means. A row with two owners
  685. // counts for both of them here: it is one commitment but two people have to
  686. // show up for it, and a per-person board is asking who has to show up.
  687. // Company totals must not sum these columns for the same reason Ninety's
  688. // assignments and commitments are different numbers.
  689. const sheetBy = new Map<string, { ours: number; waiting: number }>()
  690. for (const t of snap.sheet.data) {
  691. const cls: TrackerClass | null = classifyTrackerStatus(t.status)
  692. if (cls !== 'ours' && cls !== 'waiting') continue
  693. for (const owner of t.owners) {
  694. const person = personFromTrackerOwner(owner)
  695. if (!person) continue
  696. const cur = sheetBy.get(person.name) ?? { ours: 0, waiting: 0 }
  697. cur[cls]++
  698. sheetBy.set(person.name, cur)
  699. }
  700. }
  701. const rows: PersonRow[] = MERIDIAN_PEOPLE.map((p: Person) => {
  702. const coversPlinth = p.coveredBy.some((c) => c.startsWith('plinth-'))
  703. const coversTasks = p.coveredBy.includes('bitrix-tasks')
  704. const coversSheet = p.coveredBy.includes('content-tracker')
  705. const theirSheet = coversSheet && snap.sheet.ok ? (sheetBy.get(p.name) ?? { ours: 0, waiting: 0 }) : null
  706. const theirTodos = snap.ninety.ok
  707. ? (todosByPerson.find((t) => t.name === p.name) ?? { open: 0, late: 0 })
  708. : null
  709. const mine = coversPlinth
  710. ? lines.filter((l) => l.productionOwner === p.name || l.accountOwner === p.name)
  711. : []
  712. const ourMove = mine.filter((l) => classifyLine(l) === 'ours')
  713. const waiting = mine.filter((l) => classifyLine(l) === 'client')
  714. // By id: `responsibleId` is Bitrix's numeric user id, so a name lookup on
  715. // it matches nobody. Roster ids are pinned from user.get.
  716. const theirTasks = coversTasks
  717. ? realTasks.filter((t) => personFromBitrix(t.responsibleId)?.name === p.name)
  718. : []
  719. const ages = [
  720. ...ourMove.map((l) => l.ageDays),
  721. ...theirTasks.map((t) => t.ageDays),
  722. ].filter((d): d is number => d !== null)
  723. return {
  724. name: p.name,
  725. role: p.role,
  726. plinthOurMove: coversPlinth ? ourMove.length : null,
  727. plinthWaiting: coversPlinth ? waiting.length : null,
  728. taskCount: coversTasks ? theirTasks.length : null,
  729. taskOverdue: coversTasks ? theirTasks.filter((t) => t.overdue).length : null,
  730. oldestDays: ages.length ? Math.max(...ages) : null,
  731. todos: theirTodos ? theirTodos.open : null,
  732. todosLate: theirTodos ? theirTodos.late : null,
  733. sheetOurMove: theirSheet ? theirSheet.ours : null,
  734. sheetWaiting: theirSheet ? theirSheet.waiting : null,
  735. coverage: p.coveredBy.map((c) => SOURCE_LABELS[c]),
  736. coverageNote: p.coverageNote ?? null,
  737. untracked: p.coveredBy.length === 0,
  738. }
  739. })
  740. return {
  741. rows,
  742. junkTasks: junk.length,
  743. tracker: trackerCounts(snap),
  744. taskJoin: {
  745. attributed: realTasks.filter((t) => personFromBitrix(t.responsibleId)).length,
  746. real: realTasks.length,
  747. junk: junk.length,
  748. offRoster: offRosterBitrixIds(realTasks.map((t) => t.responsibleId)),
  749. unpinned: unpinnedBitrixPeople(),
  750. },
  751. offRosterAccounts: [...offRoster].sort(),
  752. commitmentWindowDays: COMMITMENT_WINDOW_DAYS,
  753. }
  754. }
  755. /**
  756. * One actionable row per order line: the number a person can paste into Plinth,
  757. * and the name of whoever is holding it.
  758. *
  759. * A count of "44 our move" tells leadership the shape of the day; it tells a
  760. * designer nothing they can act on. A list of order numbers with their name
  761. * against them does.
  762. *
  763. * Rules carried over from the rest of the file, restated because they decide
  764. * what is in this list:
  765. * - Product status, gated on the order not having shipped. The product row
  766. * says what the work is; the order says whether it is still here.
  767. * - Only 'ours'. A line waiting on the client is a follow-up, not a queue.
  768. * - `designer: null` renders as "unassigned", never as a blank cell. A blank
  769. * reads as "no designer needed" and this is the opposite of that.
  770. */
  771. export interface OrderQueueRow {
  772. /** "MD2639582". Pasteable into Plinth. See displayOrderNumber(). */
  773. orderNumber: string
  774. orderId: number
  775. /**
  776. * Null when the source has no product name, which is the case whenever the
  777. * row came from an order-level export. Renders as "product not in export",
  778. * never as the order number repeated back. A column that echoes the column
  779. * beside it is noise wearing the costume of data.
  780. */
  781. product: string | null
  782. quantity: number
  783. /** The real one. Product-level. */
  784. status: string
  785. /** Designer (Plinth production_owner). Null when unassigned or not reported. */
  786. designer: string | null
  787. /** CS rep (Plinth account_owner). Null when uncovered. */
  788. accountOwner: string | null
  789. customer: string
  790. company: string | null
  791. ageDays: number | null
  792. designQueue: boolean
  793. }
  794. export function orderQueue(lines: OrderLine[]): OrderQueueRow[] {
  795. return lines
  796. .filter((l) => classifyLine(l) === 'ours')
  797. .map((l) => {
  798. const orderNumber = displayOrderNumber(l.orderId, l.placedAt)
  799. const title = l.productTitle?.trim() ?? ''
  800. return {
  801. orderNumber,
  802. orderId: l.orderId,
  803. // An order-level export puts the order number in the title slot. Echoing
  804. // it back as a product name is worse than admitting we do not have one.
  805. product: !title || title === orderNumber || title === String(l.orderId) ? null : title,
  806. quantity: l.quantity,
  807. status: l.productStatus || l.orderStatus,
  808. designer: l.productionOwner,
  809. accountOwner: l.accountOwner,
  810. customer: l.customer,
  811. company: l.company,
  812. ageDays: l.ageDays,
  813. designQueue: isLineDesignQueue(l),
  814. }
  815. })
  816. // Oldest first. The top of this list is what is actually rotting.
  817. .sort((a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1))
  818. }
  819. /** The same queue, grouped under the person holding it. Unassigned last. */
  820. export function orderQueueByDesigner(lines: OrderLine[]): {
  821. designer: string | null
  822. rows: OrderQueueRow[]
  823. }[] {
  824. const rows = orderQueue(lines)
  825. const by = new Map<string, OrderQueueRow[]>()
  826. for (const r of rows) {
  827. const key = r.designer ?? '\u{10FFFF}unassigned'
  828. by.set(key, [...(by.get(key) ?? []), r])
  829. }
  830. return [...by.entries()]
  831. .map(([key, list]) => ({
  832. designer: key.startsWith('\u{10FFFF}') ? null : key,
  833. rows: list,
  834. }))
  835. .sort((a, b) => {
  836. if (a.designer === null) return 1
  837. if (b.designer === null) return -1
  838. return b.rows.length - a.rows.length
  839. })
  840. }
  841. /**
  842. * Open to-dos per person, using Meridian's review-gate rule rather than raw overdue.
  843. *
  844. * Not raw `overdue`, because nothing may be marked complete outside a Monday or Friday
  845. * review, by policy. So raw days-overdue inflates every weekend and resets every
  846. * Monday, which trains people to ignore the number. `isActuallyLate` means
  847. * overdue and at least one review has passed since it came due.
  848. *
  849. * And the thing that must reach the briefing prompt: an open to-do does not mean
  850. * unfinished work. It may be done and waiting for sign-off. Any sentence written
  851. * from this data has to survive that being true.
  852. */
  853. export interface TodoLoad {
  854. name: string
  855. open: number
  856. late: number
  857. oldestGatesMissed: number
  858. }
  859. export function todoLoad(todos: Todo[], now = new Date()): TodoLoad[] {
  860. const by = new Map<string, Todo[]>()
  861. for (const t of todos) {
  862. // Roster-resolved, not name-matched. Ninety spells people differently from
  863. // config ("Samuel Whitaker" vs "Sam Whitaker"), so an equality join would
  864. // drop people to zero without saying so. Unmapped owners keep their
  865. // Ninety spelling here and are reported via dataHealth.unmappedTodoOwners.
  866. const key = personFromNinety(t.owner.id, t.owner.name)?.name ?? t.owner.name
  867. by.set(key, [...(by.get(key) ?? []), t])
  868. }
  869. return [...by.entries()]
  870. .map(([name, list]) => ({
  871. name,
  872. open: list.length,
  873. late: list.filter((t) => isActuallyLate(t.dueDate, now)).length,
  874. oldestGatesMissed: list.reduce((m, t) => Math.max(m, reviewGatesMissed(t.dueDate, now)), 0),
  875. }))
  876. .sort((a, b) => b.late - a.late || b.open - a.open)
  877. }
  878. export function agedLeads(leads: Lead[], now = new Date()): (Lead & { ageDays: number })[] {
  879. return leads
  880. .filter((l) => !l.noise && l.createdAt)
  881. .map((l) => ({
  882. ...l,
  883. ageDays: Math.floor((now.getTime() - Date.parse(l.createdAt!)) / 86_400_000),
  884. }))
  885. .sort((a, b) => b.ageDays - a.ageDays)
  886. }