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

view-model.ts

Language TypeScript62.7 KB1566 lines

apps/dashboard/src/view-model.ts

  1. /**
  2. * Everything the six surfaces render, derived once from a Snapshot.
  3. *
  4. * `collect.ts` decides what the numbers are. This file decides what they look
  5. * like: labels, colors, bar widths, the sentences in the briefing. Keeping the
  6. * split means a classification argument is settled in one file and a layout
  7. * argument in another, and neither can quietly change the other's answer.
  8. *
  9. * The prime invariant, restated because every function here can break it: a person or
  10. * a metric that no source covers must come out as `null`, and must render as
  11. * "not tracked". Never 0. A zero means free capacity and an absent number does
  12. * not, and at a glance they are the same shape.
  13. */
  14. import { classifyOrder, isOrderDesignQueue, type OrderLine } from '@meridian/plinth-client'
  15. import { partitionTasks, type Lead, type Task } from '@meridian/bitrix-client'
  16. import { isActuallyLate, localDay, reviewGatesMissed, MERIDIAN_TZ, type Todo } from '@meridian/ninety-client'
  17. import { classify as classifyLead, stageName } from '../../../config/bitrix-stages.js'
  18. import {
  19. MERIDIAN_PEOPLE,
  20. personFromBitrix,
  21. personFromNinety,
  22. personFromTrackerOwner,
  23. uncoveredByBitrix,
  24. unpinnedBitrixPeople,
  25. } from '../../../config/meridian-people.js'
  26. import { classifyTrackerStatus, TRACKER_TITLE } from '../../../config/content-tracker.js'
  27. import { COMMITMENT_WINDOW_DAYS } from '../../../config/fathom-people.js'
  28. import {
  29. agedLeads,
  30. orderQueueByDesigner,
  31. peopleBoard,
  32. productStages,
  33. trackerCounts,
  34. type Headline,
  35. type OrderQueueRow,
  36. type PeopleBoard,
  37. type MeetingRecord,
  38. type PersonRow,
  39. type Snapshot,
  40. type StageCount,
  41. } from './collect.js'
  42. import { C, CLASS_COLOR, UNTRACKED_FILL } from './theme.js'
  43. import { plural } from './html.js'
  44. import { delta, previousRun, type Delta, type HistoryRow } from './history.js'
  45. import { COMPANY_VIEWER, type Viewer } from './viewer.js'
  46. // --- sources ----------------------------------------------------------------
  47. export type SourceTone = 'live' | 'degraded' | 'absent' | 'failed'
  48. export interface SourceStatus {
  49. name: string
  50. covers: string
  51. /** Short label for the pill and the health table. */
  52. status: string
  53. tone: SourceTone
  54. note: string
  55. }
  56. const TONE_COLOR: Record<SourceTone, string> = {
  57. live: C.green,
  58. degraded: C.orange,
  59. absent: C.gray,
  60. failed: C.magenta,
  61. }
  62. export const toneColor = (t: SourceTone): string => TONE_COLOR[t]
  63. const daysSince = (iso: string | null): number | null =>
  64. iso ? Math.floor((Date.now() - Date.parse(iso)) / 86_400_000) : null
  65. /**
  66. * The Bitrix join in numbers a reader can check against the page.
  67. *
  68. * Off-roster holders are stated and explicitly not called a gap: almost every
  69. * Bitrix account is out of scope for this board, and most of those are already
  70. * inactive in Bitrix itself. Reporting them as unmapped would be the
  71. * `unmappedNinetyOwners()` treatment applied where it does not belong: a
  72. * permanent warning about work nobody intends to show.
  73. */
  74. function describeTaskJoin(snap: Snapshot): string {
  75. const { real, junk } = partitionTasks(snap.tasks.data)
  76. const attributed = real.filter((t) => personFromBitrix(t.responsibleId)).length
  77. const off = real.length - attributed
  78. const unpinned = unpinnedBitrixPeople()
  79. // Somebody Bitrix does not cover is a third state. `unpinnedBitrixPeople()`
  80. // only reports people who claim bitrix-tasks coverage and lack an id: the
  81. // case where tasks cannot attribute. A person with no id and no claim (a new
  82. // hire, say) is not that case, and without this line the page would claim
  83. // every roster id is pinned about a person it never looked up. Their tasks
  84. // read "not tracked" on their row; this is the surface that says why.
  85. const uncovered = uncoveredByBitrix()
  86. const pinned = MERIDIAN_PEOPLE.filter((p) => p.bitrixUserId).length
  87. return (
  88. `${attributed} of ${real.length} open tasks attributed to the roster by Bitrix user id; ` +
  89. `${junk.length} withheld as automation exhaust, test and demo rows. ` +
  90. (off
  91. ? `${off} held by ${off === 1 ? 'an account' : 'accounts'} outside the roster. That is expected, not a gap: ` +
  92. `the directory carries the whole company's history and Bitrix marks most of it inactive. `
  93. : '') +
  94. (unpinned.length
  95. ? `NO BITRIX ID PINNED for ${unpinned.join(', ')}, so their tasks cannot attribute at all.`
  96. : `${pinned} of ${MERIDIAN_PEOPLE.length} roster ids are pinned.`) +
  97. (uncovered.length
  98. ? ` Bitrix does not cover ${uncovered.join(', ')} at all: no user id is known, so their ` +
  99. `task counts read as not tracked rather than as zero. Fill it with a Bitrix user.get ` +
  100. `lookup.`
  101. : '')
  102. )
  103. }
  104. /**
  105. * The tracker, said plainly, including the two things it cannot tell us.
  106. *
  107. * How stale it is: unknown, and that is stated rather than skipped. Google's
  108. * public CSV endpoint sends no `Last-Modified`, and the document's real
  109. * `modifiedTime` needs the Drive API and a credential the Worker does not have.
  110. * So the one input most likely to go quietly stale is the one we can say the
  111. * least about. Leaving the sentence out would let a reader assume it is current;
  112. * "not tracked" is the honest rendering of an absence, here as everywhere.
  113. */
  114. function describeTracker(snap: Snapshot): string {
  115. const t = trackerCounts(snap)
  116. const tabs = snap.sheetTabs.ok.map((x) => x.label).join(', ')
  117. const parts = [
  118. `${t.ours} open, ${t.waiting} ready to launch, ${t.done} launched across ${snap.sheetTabs.ok.length} tab${snap.sheetTabs.ok.length === 1 ? '' : 's'} (${tabs}).`,
  119. ]
  120. if (snap.sheetTabs.failed.length) {
  121. parts.push(
  122. `${snap.sheetTabs.failed.map((f) => `${f.label} (${f.reason})`).join('; ')} could not be read, so this is a PARTIAL view of the sheet.`,
  123. )
  124. }
  125. if (t.unclassified) {
  126. parts.push(
  127. `${t.unclassified} row${t.unclassified === 1 ? '' : 's'} counted as NEITHER open nor done because the Status cell reads ${t.unknownStatuses.join(', ')}: unclassified, not assumed.`,
  128. )
  129. }
  130. if (t.unmappedOwners.length) {
  131. parts.push(
  132. `Owner${t.unmappedOwners.length === 1 ? '' : 's'} matching nobody on the roster: ${t.unmappedOwners.join(', ')}. Their rows are on no one's board.`,
  133. )
  134. }
  135. parts.push(
  136. `Hand-maintained, and read from the sheet's public CSV export, which sends no last-modified, so HOW FRESH THIS IS cannot be measured here and is not tracked. ${TRACKER_TITLE}.`,
  137. )
  138. return parts.join(' ')
  139. }
  140. /**
  141. * One row per source, in the order a reader should audit them.
  142. *
  143. * A failed source reports as failed with its error text. The whole point is that
  144. * zero and broken never look the same. A source that died and a source with
  145. * nothing in it produce the same empty chart, and only one of them is a problem.
  146. */
  147. export function sourceStatuses(snap: Snapshot): SourceStatus[] {
  148. // A fixture run has not talked to anything. Reporting "live" over a frozen
  149. // export is the exact lie this board exists to prevent, so every pill is
  150. // rewritten rather than trusted.
  151. if (snap.mode === 'fixtures') {
  152. const captured = snap.fixtureCapturedAt
  153. const age = daysSince(captured)
  154. const staleness =
  155. age === null ? '' : ` Captured ${age} days ago; some of these orders have moved on since.`
  156. // Failure outranks sample. A source that threw must read as failed even in
  157. // a fixture run: "sample data" over a dead source hides a real break behind
  158. // a reassuring label, which is the same masking bug one level down.
  159. const failed: Record<string, string | null> = {
  160. Plinth: snap.orderLines.ok ? null : snap.orderLines.error,
  161. 'Bitrix leads': snap.leads.ok ? null : snap.leads.error,
  162. 'Bitrix tasks': snap.tasks.ok ? null : snap.tasks.error,
  163. 'Ninety (90)': snap.ninety.ok ? null : snap.ninety.error,
  164. 'Content Creation tracker': snap.sheet.ok ? null : snap.sheet.error,
  165. }
  166. const sample = (name: string, covers: string, note: string): SourceStatus => {
  167. const err = failed[name]
  168. if (err) {
  169. return {
  170. name,
  171. covers,
  172. status: 'failed',
  173. tone: 'failed',
  174. note: `Request failed: ${err}. Missing from this page, not zero.`,
  175. }
  176. }
  177. return { name, covers, status: 'sample data', tone: 'degraded', note }
  178. }
  179. return [
  180. sample('Plinth', 'orders, product lines',
  181. // Sliced, not localized, and that is not the bug it looks like.
  182. // SEED_EXPORTED_AT is a date wearing an instant's clothes (midnight
  183. // UTC, meaning "the export was taken that day"). Midnight UTC is 7 PM
  184. // the previous evening in Chicago, so running it through localDay()
  185. // would render the day before and misstate when the export was taken. Converting a date-only value through a timezone is the mirror
  186. // image of the bug dates.ts exists to prevent, and just as wrong.
  187. `Frozen export from ${captured?.slice(0, 10) ?? 'an earlier date'}, not a live query.${staleness} ` +
  188. `An order shown here as open may already be shipped.`),
  189. sample('Bitrix leads', 'lead pipeline', 'Invented leads shaped like the real ones. No request was made.'),
  190. sample('Bitrix tasks', 'internal tasks', 'Sample tasks, including the junk rows, because the junk is the point.'),
  191. sample('Plinth assignments', 'who owns which order line', 'Assignments come from the same frozen export.'),
  192. sample('Fathom', 'meeting commitments', 'Static seed. Unchanged in fixture and live runs alike.'),
  193. sample('Ninety (90)', 'company to-dos',
  194. 'Sample to-dos shaped like the live company sweep, including one commitment carried by three people.'),
  195. ]
  196. }
  197. // A dedicated row, not a note on the Plinth row. Assignments ride the same
  198. // request as the orders, so they share its freshness, but they can degrade on
  199. // their own: Plinth can answer perfectly and still stop sending these two fields,
  200. // which is why coverage is counted and shown rather than assumed.
  201. const cov = snap.orderWindow.assignmentFields
  202. const assignmentBase = { name: 'Plinth assignments', covers: 'who owns which order line' }
  203. let assignmentRow: SourceStatus
  204. if (!snap.orderLines.ok) {
  205. assignmentRow = {
  206. ...assignmentBase,
  207. status: 'failed',
  208. tone: 'failed',
  209. note: `Assignments ride the Plinth orders fetch, which failed: ${snap.orderLines.error}. Per-person load is missing, not zero.`,
  210. }
  211. } else if (cov.total === 0) {
  212. assignmentRow = {
  213. ...assignmentBase,
  214. status: 'no lines',
  215. tone: 'degraded',
  216. note: 'The order window returned no product lines, so whether the assignment fields are still being sent cannot be judged this run.',
  217. }
  218. } else if (cov.production === 0 && cov.accountOwner === 0) {
  219. assignmentRow = {
  220. ...assignmentBase,
  221. status: 'fields missing',
  222. tone: 'failed',
  223. note: `orders.fetch returned neither production_owner nor account_owner on any of the ${cov.total} fetched lines. They arrived 2026-09-02 and have stopped arriving. Per-person load is missing, not zero.`,
  224. }
  225. } else {
  226. const worst = Math.min(cov.production, cov.accountOwner)
  227. const pct = Math.round((worst / cov.total) * 100)
  228. assignmentRow =
  229. pct < 100
  230. ? {
  231. ...assignmentBase,
  232. status: `${pct}% of lines`,
  233. tone: 'degraded',
  234. note: `production_owner on ${cov.production} and account_owner on ${cov.accountOwner} of ${cov.total} lines. Lines without a field render unassigned, so per-person counts are a floor.`,
  235. }
  236. : {
  237. ...assignmentBase,
  238. status: 'live',
  239. tone: 'live',
  240. note: 'production_owner and account_owner arrive on the same orders.fetch response as the orders (added by Plinth 2026-09-02), order level and per product line, as fresh as the Plinth pill. The manual CSV export is retired.',
  241. }
  242. }
  243. // Which keys came back short, named rather than counted. A pill that said
  244. // "1 key failed" would send the reader to the logs; a pill that says whose
  245. // key it was sends them to the person who can re-issue it.
  246. const fathomDark = snap.fathomSources.filter((k) => !k.ok)
  247. const fathomCapped = snap.fathomSources.filter((k) => k.ok && k.truncated)
  248. return [
  249. {
  250. name: 'Plinth',
  251. covers: 'orders, product lines',
  252. status: snap.orderLines.ok ? 'live' : 'failed',
  253. tone: snap.orderLines.ok ? 'live' : 'failed',
  254. note: snap.orderLines.ok
  255. ? 'Order lines are classified on the PRODUCT status, and only counted while the order has not moved past Part Dispatched. Both fields go stale in opposite directions: some orders read "On Press" with artwork still in review, and product rows still read "File Check" on orders that have shipped.'
  256. : `Request failed: ${snap.orderLines.error}. Every order number on this page is missing, not zero.`,
  257. },
  258. {
  259. name: 'Bitrix leads',
  260. covers: 'lead pipeline',
  261. status: snap.leads.ok ? 'live' : 'failed',
  262. tone: snap.leads.ok ? 'live' : 'failed',
  263. note: snap.leads.ok
  264. ? `${snap.leads.data.filter((l) => l.noise).length} automated rows excluded as noise: security emails, call logs, website chat. The excluded count is always shown; silent filtering is how a dashboard starts lying.`
  265. : `Request failed: ${snap.leads.error}. No lead number on this page is real.`,
  266. },
  267. {
  268. name: 'Bitrix tasks',
  269. covers: 'internal tasks',
  270. status: snap.tasks.ok ? 'live' : 'failed',
  271. tone: snap.tasks.ok ? 'live' : 'failed',
  272. // Counts, not a description of the method. A sentence describing how the
  273. // join works can read as "fine" on a join that attributed nothing; the
  274. // numbers cannot.
  275. note: snap.tasks.ok ? describeTaskJoin(snap) : `Request failed: ${snap.tasks.error}.`,
  276. },
  277. {
  278. name: 'Content Creation tracker',
  279. covers: 'internal design, photo, copy and newsletter work',
  280. // Not "live". Every other pill here means "we queried a system of record
  281. // just now"; this one means "we read a spreadsheet a person types into".
  282. // Borrowing the same word for both would make the least reliable input on
  283. // this board look like the most current.
  284. status: snap.sheet.ok ? (snap.sheetTabs.failed.length ? 'partial' : 'read') : 'failed',
  285. tone: snap.sheet.ok ? (snap.sheetTabs.failed.length ? 'degraded' : 'live') : 'failed',
  286. note: snap.sheet.ok ? describeTracker(snap) : `Could not read the sheet: ${snap.sheet.error}.`,
  287. },
  288. assignmentRow,
  289. // Fathom's pill has to say what kind of live. Read `not configured` as
  290. // absent rather than as zero: nobody was asked.
  291. !snap.fathomConfigured
  292. ? {
  293. name: 'Fathom',
  294. covers: 'meeting commitments',
  295. status: 'not configured',
  296. tone: 'absent' as const,
  297. note: 'No Fathom key is set, so meeting commitments read "not tracked" rather than zero. Keys are per user; see config/fathom-keys.ts.',
  298. }
  299. : !snap.meetings.ok
  300. ? {
  301. name: 'Fathom',
  302. covers: 'meeting commitments',
  303. status: 'failed',
  304. tone: 'failed' as const,
  305. note: `Request failed: ${snap.meetings.error}. Meeting commitments are missing from this page, not zero.`,
  306. }
  307. : {
  308. name: 'Fathom',
  309. covers: 'meeting commitments',
  310. // Partial is its own state, the same as the tracker's failed tabs.
  311. // With several keys, "some of the company's meetings were read" is a
  312. // real outcome, and calling it `live` would put a clean pill over an
  313. // incomplete list: the prime invariant applied to the pill itself.
  314. status: fathomDark.length || fathomCapped.length ? 'partial' : 'live',
  315. tone: fathomDark.length || fathomCapped.length ? ('degraded' as const) : ('live' as const),
  316. note:
  317. `${snap.meetings.data.length} meetings in the last ${COMMITMENT_WINDOW_DAYS} days, ` +
  318. `from ${snap.fathomSources.length} ${plural(snap.fathomSources.length, 'key')} ` +
  319. `(${snap.fathomSources.map((k) => k.owner).join(', ')}). ` +
  320. // Most of the roster has handed over no key. Some of their
  321. // recordings reach the board through the keys we hold (a key
  322. // returns meetings recorded by other people too), but whether all
  323. // of them do cannot be known from here, so a person at zero is not
  324. // proof of an empty fortnight.
  325. `Fathom has no org keys. The board sees the union of what its keys return; ` +
  326. `a person showing few or no meetings may simply have recorded ones no key here ` +
  327. `can reach, so zero is a floor, not a fact. ` +
  328. (fathomDark.length
  329. ? `${fathomDark.map((k) => `${k.owner}'s key failed: ${k.error}`).join(' ')} ` +
  330. `Their meetings are missing from this run, not empty. `
  331. : '') +
  332. (fathomCapped.length
  333. ? `${fathomCapped.map((k) => k.owner).join(', ')} hit the page cap, so that ` +
  334. `sweep is the newest part of the window and not all of it. `
  335. : '') +
  336. `EVERY PAGE SHOWS ONLY THE MEETINGS ITS READER ATTENDED. The keys fetch, the ` +
  337. `board filters per viewer. Commitments have no completion state, so they are ` +
  338. `windowed rather than accumulated.`,
  339. },
  340. {
  341. name: 'Ninety (90)',
  342. covers: 'company to-dos',
  343. status: snap.ninety.ok ? 'live' : 'failed',
  344. tone: snap.ninety.ok ? 'live' : 'failed',
  345. note: snap.ninety.ok
  346. ? `Every non-personal to-do in the company, from one Owner/Admin token via ` +
  347. `GET /todos/company. ${describeSpread(snap)} Rocks and issues are not connected yet.`
  348. : // The status-specific explanation lives in the client's own error
  349. // (rule 1). What this line adds is the part that is true of every
  350. // Ninety failure: the numbers are missing, not zero.
  351. `Request failed: ${snap.ninety.error} Every commitment number on this ` +
  352. `page is missing, not zero.`,
  353. },
  354. ]
  355. }
  356. /**
  357. * The sentence that stops the count being argued about.
  358. *
  359. * A bare row count overstates the work whenever an item is carried by more than
  360. * one person, and on live data that can be about a quarter of the rows. Saying both
  361. * numbers in the health table means nobody has to discover the difference during
  362. * a meeting.
  363. */
  364. function describeSpread(snap: Snapshot): string {
  365. const s = snap.ninetySpread
  366. if (!s) return ''
  367. if (!s.sharedCommitments) return `${s.commitments} commitments, none shared.`
  368. return (
  369. `${s.assignments} assignments across ${s.commitments} distinct commitments, ` +
  370. `${s.sharedCommitments} carried by more than one person, up to ${s.maxOwners}. ` +
  371. `Per-person views keep every row; company counts dedupe.`
  372. )
  373. }
  374. // --- headline tiles and their drill-downs ------------------------------------
  375. export interface DrillRow {
  376. id: string
  377. /** "<stage>: <subject>", for the drill table where the stage is the point. */
  378. what: string
  379. /** The subject alone. The briefing sets its own context, so repeating the
  380. * stage there produces "moved to Client Replied: Client Replied: …". */
  381. subject: string
  382. who: string
  383. age: string
  384. }
  385. export interface Drill {
  386. key: string
  387. title: string
  388. note: string
  389. rows: DrillRow[]
  390. /** Rows beyond what is shown. Stated, never silently dropped. */
  391. more: number
  392. }
  393. const DRILL_LIMIT = 12
  394. const ownerOf = (l: OrderLine): string => l.productionOwner ?? l.accountOwner ?? 'unassigned'
  395. const ageOf = (d: number | null): string => (d === null ? '–' : `${d}d`)
  396. const orderRow = (l: OrderLine): DrillRow => {
  397. const who = l.company ?? l.customer ?? null
  398. const product = l.productTitle || null
  399. const subject = [who, product && product !== who ? product : null].filter(Boolean).join(' · ') || 'order line'
  400. return {
  401. id: String(l.orderId),
  402. what: `${l.productStatus}: ${subject}`,
  403. subject,
  404. who: ownerOf(l),
  405. age: ageOf(l.ageDays),
  406. }
  407. }
  408. function leadRow(l: Lead & { ageDays: number }): DrillRow {
  409. return {
  410. id: `L-${l.id}`,
  411. what: `${stageName(l.statusId)}: ${l.title}`,
  412. subject: l.company ?? l.title,
  413. who: l.company ?? '–',
  414. age: `${l.ageDays}d`,
  415. }
  416. }
  417. /** Oldest first: the thing that has waited longest is the thing to look at. */
  418. const byAgeDesc = <T extends { age: string }>(rows: T[]): T[] =>
  419. [...rows].sort((a, b) => (parseInt(b.age, 10) || 0) - (parseInt(a.age, 10) || 0))
  420. function cut(rows: DrillRow[]): { rows: DrillRow[]; more: number } {
  421. const sorted = byAgeDesc(rows)
  422. return { rows: sorted.slice(0, DRILL_LIMIT), more: Math.max(0, sorted.length - DRILL_LIMIT) }
  423. }
  424. /** Order statuses that need a decision from a person, not a step in a queue. */
  425. const EXCEPTION_STATUSES = /gateway|cancellation|refund/i
  426. export function drills(snap: Snapshot): Record<string, Drill> {
  427. const lines = snap.orderLines.data
  428. const leads = agedLeads(snap.leads.data)
  429. const cls = (l: OrderLine) => classifyOrder(l.orderStatus, l.orderStatusId || null)
  430. const oursLines = lines.filter((l) => cls(l) === 'ours')
  431. const oursLeads = leads.filter((l) => classifyLead(l.statusId, l.semantic) === 'ours')
  432. const design = lines.filter((l) => isOrderDesignQueue(l.orderStatus, l.orderStatusId || null))
  433. const replied = leads.filter((l) => stageName(l.statusId) === 'Client Replied')
  434. const exceptions = lines.filter((l) => EXCEPTION_STATUSES.test(l.orderStatus))
  435. const mk = (key: string, title: string, note: string, rows: DrillRow[]): Drill => ({
  436. key,
  437. title,
  438. note,
  439. ...cut(rows),
  440. })
  441. return {
  442. ours: mk(
  443. 'ours',
  444. `Our move · ${oursLines.length + oursLeads.length} items`,
  445. `${oursLines.length} order lines from Plinth and ${oursLeads.length} leads from Bitrix. Oldest first.`,
  446. [...oursLines.map(orderRow), ...oursLeads.map(leadRow)],
  447. ),
  448. design: mk(
  449. 'design',
  450. `Design queue · ${design.length} in artwork`,
  451. 'The closest proxy we have for open mockups. It is a proxy, not the number; see Data health.',
  452. design.map(orderRow),
  453. ),
  454. replied: mk(
  455. 'replied',
  456. `Client replied · ${replied.length} lead${replied.length === 1 ? '' : 's'}`,
  457. 'They are waiting on us and nobody else. This always surfaces first.',
  458. replied.map(leadRow),
  459. ),
  460. exceptions: mk(
  461. 'exceptions',
  462. `Exceptions · ${exceptions.length} items`,
  463. 'Payment gateway malfunctions, cancellation requests and refund claims. Each needs a decision, not a task.',
  464. exceptions.map(orderRow),
  465. ),
  466. }
  467. }
  468. // --- person board ------------------------------------------------------------
  469. export type LoadBasis = 'ours' | 'all'
  470. export interface PersonView extends PersonRow {
  471. short: string
  472. /** Null when nothing covers them. Renders as "not tracked". */
  473. load: number | null
  474. loadNote: string
  475. pct: string
  476. fill: string
  477. detail: string
  478. aside: string
  479. /** Tasks past at least one Monday review gate, Meridian's definition of late. */
  480. actuallyLate: number
  481. }
  482. const firstName = (n: string): string => n.split(' ')[0] ?? n
  483. export function personViews(
  484. board: PeopleBoard,
  485. tasks: Task[],
  486. basis: LoadBasis = 'ours',
  487. ): PersonView[] {
  488. const { real } = partitionTasks(tasks)
  489. const lateFor = (name: string): number =>
  490. real.filter((t) => personFromBitrix(t.responsibleId)?.name === name && reviewGatesMissed(t.deadline) > 0)
  491. .length
  492. const loadOf = (r: PersonRow): number | null => {
  493. if (r.untracked) return null
  494. const ours = r.plinthOurMove ?? 0
  495. const waiting = basis === 'all' ? (r.plinthWaiting ?? 0) : 0
  496. // Ninety to-dos and tracker rows are part of the load: they are real work
  497. // this person owes a move on, and without them people who hold no order
  498. // lines read as idle on the panel called "workload by person".
  499. return ours + waiting + (r.taskCount ?? 0) + (r.todos ?? 0) + (r.sheetOurMove ?? 0)
  500. }
  501. const loads = board.rows.map(loadOf).filter((n): n is number => n !== null)
  502. const max = Math.max(1, ...loads)
  503. return board.rows.map((r) => {
  504. const load = loadOf(r)
  505. const late = lateFor(r.name)
  506. const parts: string[] = []
  507. if (r.plinthOurMove !== null) parts.push(`${r.plinthOurMove} order${r.plinthOurMove === 1 ? '' : 's'}`)
  508. else parts.push('orders not tracked')
  509. if (r.taskCount !== null) parts.push(`${r.taskCount} task${r.taskCount === 1 ? '' : 's'}`)
  510. // Not "commitment": the "I said I would" block on My Day calls Fathom action
  511. // items commitments, and these are Ninety to-dos. One label for two sources
  512. // makes a reader think one number covers both.
  513. if (r.todos !== null) parts.push(`${r.todos} to-do${r.todos === 1 ? '' : 's'}`)
  514. if (r.sheetOurMove !== null)
  515. parts.push(`${r.sheetOurMove} tracker item${r.sheetOurMove === 1 ? '' : 's'}`)
  516. const aside: string[] = []
  517. if (r.plinthWaiting) aside.push(`${r.plinthWaiting} waiting on client`)
  518. // Finished by them, blocked on the launch step. Aside, never load: counting
  519. // it as backlog is how a board makes someone look behind for work they did.
  520. if (r.sheetWaiting) aside.push(`${r.sheetWaiting} ready to launch`)
  521. // `+`, not `||`: a person with one late Bitrix task must still report their
  522. // late Ninety to-dos too.
  523. const pastReview = late + (r.todosLate ?? 0)
  524. if (pastReview) aside.push(`${pastReview} past a review gate`)
  525. if (r.oldestDays !== null) aside.push(`oldest ${r.oldestDays}d`)
  526. return {
  527. ...r,
  528. short: firstName(r.name),
  529. load,
  530. loadNote: r.untracked
  531. ? 'no covering source'
  532. : r.plinthOurMove === null
  533. ? 'tasks + to-dos'
  534. : 'open items',
  535. pct: load === null ? '100%' : `${Math.round((load / max) * 100)}%`,
  536. // A hatched bar for partial coverage: it reads as "this number is not the
  537. // whole story" without needing the legend.
  538. fill:
  539. load === null || r.plinthOurMove === null
  540. ? UNTRACKED_FILL
  541. : (r.oldestDays ?? 0) >= 20
  542. ? C.magenta
  543. : C.blue,
  544. detail: parts.join(' + '),
  545. aside: aside.join(' · '),
  546. actuallyLate: late,
  547. }
  548. })
  549. }
  550. // --- my day ------------------------------------------------------------------
  551. export interface WorkItem {
  552. what: string
  553. meta: string
  554. age: string
  555. color: string
  556. late: boolean
  557. /** Which source this row came from. My Day groups by it, per the redesign. */
  558. kind: 'order' | 'task' | 'todo' | 'tracker'
  559. /** Pasteable identifier when the source has one (order numbers). */
  560. id?: string
  561. /**
  562. * Key for the shared user note on this item: `order:MD26…` / `task:<id>`.
  563. * Only orders and Bitrix tasks carry one: they are the rows leadership asks
  564. * "why is this sitting" about.
  565. */
  566. noteKey?: string
  567. }
  568. export interface MyDay {
  569. person: PersonView
  570. ourMove: number
  571. waiting: number
  572. tasks: number
  573. /** Open Ninety to-dos listed on this card. */
  574. todos: number
  575. /** Content Creation tracker rows this person owes a move on. */
  576. tracker: number
  577. /**
  578. * To-dos counted in the header but deliberately not listed, because this card
  579. * does not belong to the viewer. Stated, never absorbed: a header number above
  580. * a list that cannot account for it breaks rule 9.
  581. */
  582. todosWithheld: number
  583. /**
  584. * Sources feeding this list that failed. An empty list means there is nothing
  585. * to do; a dead source means we cannot see. Those must never read the same,
  586. * and the empty state is the only place a reader would find out.
  587. */
  588. sourcesDown: string[]
  589. items: WorkItem[]
  590. /**
  591. * Action items from meetings this viewer attended, all of them, not only the
  592. * ones assigned to them. See myDay() for the three rules and for the one
  593. * consequence: an item assigned to you from a meeting you missed is not here,
  594. * because surfacing it would disclose the meeting.
  595. */
  596. commitments: {
  597. text: string
  598. meeting: string
  599. date: string
  600. url: string
  601. /** Roster owner, when the board could resolve one. */
  602. owner: string | null
  603. /** Outsider owner: a client or vendor. */
  604. externalOwner: string | null
  605. /** A name Fathom gave that config does not recognise. A gap, not a person. */
  606. unknownOwner: string | null
  607. /** Nobody named at all. The ones most likely to be dropped. */
  608. unowned: boolean
  609. }[]
  610. /**
  611. * False when meetings are not tracked: no Fathom key, or the fetch failed.
  612. * An empty `commitments` then means "we did not look", which must not render
  613. * as "you have no commitments".
  614. */
  615. commitmentsTracked: boolean
  616. /**
  617. * Set when meetings were read, but not from every configured key.
  618. *
  619. * `commitmentsTracked` is all-or-nothing and cannot express this. With several
  620. * keys the common failure is one revoked key, and the result on this card is
  621. * a short list, not an empty one, which reads as a quiet fortnight. Null when
  622. * every key came back clean.
  623. */
  624. commitmentsPartial: string | null
  625. lateCount: number
  626. }
  627. /**
  628. * Whole days between a to-do's due day and today, in Meridian's timezone.
  629. *
  630. * Both sides are YYYY-MM-DD parsed as UTC midnight, so this is a calendar-day
  631. * difference with no DST drift. Positive means overdue. Null means no due date,
  632. * which is not the same as due today.
  633. */
  634. const daysPastDue = (dueDateLocal: string | null, now: Date = new Date()): number | null => {
  635. if (!dueDateLocal) return null
  636. const today = localDay(now, MERIDIAN_TZ)
  637. return Math.round(
  638. (Date.parse(`${today}T00:00:00Z`) - Date.parse(`${dueDateLocal}T00:00:00Z`)) / 86_400_000,
  639. )
  640. }
  641. /**
  642. * One Ninety to-do as a row on My Day.
  643. *
  644. * The age string is deliberately not the same shape as an order's. An order line
  645. * shows "34d" meaning days waiting; a to-do shows "9d over" or "due in 5d". They
  646. * are different measurements and one column showing both as a bare number would
  647. * invite a reader to compare them. `parseInt` still sorts it: "due in 5d" and
  648. * "–" both fall to 0, so nothing not yet due outranks something actually late.
  649. */
  650. export function todoItem(t: Todo, now: Date = new Date()): WorkItem {
  651. const gates = reviewGatesMissed(t.dueDate, now)
  652. const late = isActuallyLate(t.dueDate, now)
  653. const over = daysPastDue(t.dueDateLocal, now)
  654. const meta = [
  655. 'Ninety to-do',
  656. // dueDateLocal, never dueDate. The raw instant is correct for comparisons
  657. // and wrong to slice for display. See types.ts on Todo.dueDate.
  658. t.dueDateLocal ? `due ${t.dueDateLocal}` : 'no due date',
  659. // Why the same title can appear twice: recurrence is not collapsed here, on
  660. // purpose. See myDay().
  661. t.repeat,
  662. gates > 0 ? `past ${gates} review gate${gates === 1 ? '' : 's'}` : null,
  663. ].filter(Boolean)
  664. return {
  665. what: t.title,
  666. meta: meta.join(' · '),
  667. age: over === null ? '–' : over > 0 ? `${over}d over` : `due in ${-over}d`,
  668. color: late ? C.magenta : C.blueDeep,
  669. late,
  670. kind: 'todo',
  671. }
  672. }
  673. /**
  674. * The participant gate: which meetings this viewer may be shown anything about.
  675. *
  676. * This function is the security boundary for meeting content. Fathom's own
  677. * permissions cannot help: every meeting on this account reads
  678. * `shared_with: no_teams`, so one key sees everything and the filtering is ours
  679. * to get right (config/fathom-keys.ts records why).
  680. *
  681. * Three return states, and they are not interchangeable:
  682. * null: not tracked. No key configured, or the fetch failed. Never an
  683. * empty list, because "no meetings" and "we did not look" lead to
  684. * opposite conclusions and the prime invariant forbids conflating them.
  685. * []: this viewer was in none of the meetings we did fetch.
  686. * [...]: meetings this viewer attended.
  687. *
  688. * Fails closed in every ambiguous case: the company variant (no roster person),
  689. * an unidentified viewer, and a meeting whose participant list does not name
  690. * them all yield nothing. A person appearing in `unmatchedParticipants` is not
  691. * a viewer: the board could not resolve them, and resolving-by-guess is how a
  692. * leadership meeting reaches the wrong desk.
  693. */
  694. export function meetingsFor(snap: Snapshot, viewer: Viewer | null): MeetingRecord[] | null {
  695. if (!snap.fathomConfigured || !snap.meetings.ok) return null
  696. const name = viewer?.person?.name
  697. if (!name) return []
  698. return snap.meetings.data.filter((m) => m.participants.includes(name))
  699. }
  700. /**
  701. * One person's card: what is mine today, and what I promised.
  702. *
  703. * Items come from every source the header counts (order lines, Bitrix tasks,
  704. * Ninety to-dos, tracker rows), so the number above the list and the list can
  705. * account for each other (rule 9). Several people hold no order lines at all,
  706. * and a card built from orders alone would read as a number above an empty
  707. * list.
  708. *
  709. * The viewer is an argument because to-do titles attach only to the card
  710. * belonging to the viewer. A person's own to-do is not confidential from them
  711. * (Ninety shows it to them), so their own list is complete whatever team the
  712. * item carries. What `config/ninety-teams.ts` protects is other people's
  713. * Leadership items, and this surface never renders those: anything withheld is
  714. * counted in `todosWithheld` so the number above the list still adds up.
  715. *
  716. * Defaults to COMPANY_VIEWER, which owns nothing, for the reason given on
  717. * render(): getting less than you should is a bug report, getting more is an
  718. * incident.
  719. */
  720. export function myDay(snap: Snapshot, view: PersonView, viewer: Viewer = COMPANY_VIEWER): MyDay {
  721. const lines = snap.orderLines.data.filter(
  722. (l) => l.productionOwner === view.name || l.accountOwner === view.name,
  723. )
  724. const cls = (l: OrderLine) => classifyOrder(l.orderStatus, l.orderStatusId || null)
  725. const ours = lines.filter((l) => cls(l) === 'ours')
  726. const waiting = lines.filter((l) => cls(l) === 'client')
  727. const { real } = partitionTasks(snap.tasks.data)
  728. // By id, not by name; see personFromBitrix(). A name lookup on a numeric id
  729. // matches nobody.
  730. const mine = real.filter((t) => personFromBitrix(t.responsibleId)?.name === view.name)
  731. // Roster-resolved, not an equality join on the Ninety spelling. Ninety says
  732. // "Samuel Whitaker" where the roster says "Sam Whitaker", and matching on the
  733. // string drops people to zero without saying so (see todoLoad in
  734. // collect.ts). Every ninetyId is pinned, so this is an id match with a name
  735. // fallback, not a guess.
  736. //
  737. // Recurrence is not collapsed. `todoLoad()` counts every series instance, so
  738. // the header includes both; collapsing only the list would put the two back out
  739. // of agreement. `todoItem` surfaces `repeat` so a repeated title explains
  740. // itself.
  741. const isMine = viewer.ownBriefing === view.name
  742. const myTodos = isMine
  743. ? snap.ninety.data.filter(
  744. (t) => personFromNinety(t.owner.id, t.owner.name)?.name === view.name,
  745. )
  746. : []
  747. const todoItems: WorkItem[] = myTodos.map((t) => todoItem(t))
  748. const orderItems: WorkItem[] = ours.map((l) => {
  749. // Plinth often carries the order number in both fields. "MD2639585 · MD2639585"
  750. // is worse than either half on its own.
  751. const who = l.company ?? l.customer ?? null
  752. const product = l.productTitle || null
  753. const what = [who, product && product !== who ? product : null].filter(Boolean).join(' · ')
  754. return {
  755. what: what || `Order ${l.orderId}`,
  756. meta: `Order ${l.orderId} · ${l.productStatus} · Plinth`,
  757. age: ageOf(l.ageDays),
  758. color: (l.ageDays ?? 0) >= 20 ? C.magenta : C.blue,
  759. late: false,
  760. kind: 'order',
  761. id: `MD26${l.orderId}`,
  762. noteKey: `order:MD26${l.orderId}`,
  763. }
  764. })
  765. const taskItems: WorkItem[] = mine.map((t) => {
  766. const gates = reviewGatesMissed(t.deadline)
  767. return {
  768. what: t.title,
  769. meta: gates > 0 ? `Bitrix task · past ${gates} review gate${gates === 1 ? '' : 's'}` : 'Bitrix task',
  770. age: ageOf(t.ageDays),
  771. color: C.orange,
  772. late: gates > 0,
  773. kind: 'task',
  774. noteKey: `task:${t.id}`,
  775. }
  776. })
  777. // Tracker rows for this person. Shared rows appear on every named owner's
  778. // card: one commitment, but each of them has to show up for it.
  779. const myTracker = isMine
  780. ? snap.sheet.data.filter(
  781. (t) =>
  782. classifyTrackerStatus(t.status) === 'ours' &&
  783. t.owners.some((o) => personFromTrackerOwner(o)?.name === view.name),
  784. )
  785. : []
  786. const trackerItems: WorkItem[] = myTracker.map((t) => ({
  787. what: t.title,
  788. meta: [
  789. `Tracker · ${t.tab}`,
  790. t.sku,
  791. t.status || 'no status',
  792. // Shown as typed. The sheet mixes 08/19/26 and 08/19/2026 and 8/21/2026,
  793. // and inferring a year or a locale from that is how you call something
  794. // overdue that is not.
  795. t.deadline ? `due ${t.deadline}` : null,
  796. t.shared ? `with ${t.owners.filter((o) => personFromTrackerOwner(o)?.name !== view.name).join(', ')}` : null,
  797. ]
  798. .filter(Boolean)
  799. .join(' · '),
  800. // No age. The tracker has no created date and its deadlines are free text,
  801. // so there is no number here that would mean the same thing as an order's
  802. // "34d". An invented one would sort against real ages and win.
  803. age: '–',
  804. color: C.yellow,
  805. late: false,
  806. kind: 'tracker' as const,
  807. }))
  808. // Meeting commitments, participant-gated:
  809. //
  810. // - You see meetings you were in, and nothing about the ones you were not.
  811. // A leadership meeting must not reveal its task list, or its existence, to
  812. // people who were deliberately not in it.
  813. // - Inside a meeting you attended you see all of its items, not just yours.
  814. // You were in the room; you already heard them. What that buys is a record
  815. // of what the meeting produced and who owns each piece.
  816. // - Unowned items (nobody named) go to every participant, because one of
  817. // the people in the room owns it and they are the only ones who can say
  818. // which.
  819. //
  820. // A consequence worth knowing: an item assigned to you in a meeting you did
  821. // not attend does not appear here. It is not lost: everyone who was in that
  822. // room sees it with your name on it, so the routing is human. Surfacing it
  823. // here would leak the meeting.
  824. const cutoff = Date.now() - COMMITMENT_WINDOW_DAYS * 86_400_000
  825. const attended = meetingsFor(snap, viewer)
  826. const commitments =
  827. attended === null
  828. ? []
  829. : attended
  830. .filter((m) => Date.parse(m.date) >= cutoff)
  831. .flatMap((m) =>
  832. m.items.map((i) => ({
  833. text: i.text,
  834. meeting: m.title,
  835. date: m.date,
  836. url: i.playbackUrl ?? m.url,
  837. owner: i.owner,
  838. externalOwner: i.externalOwner,
  839. unknownOwner: i.unknownOwner,
  840. /** True when no name attached at all: the ones most easily missed. */
  841. unowned: !i.owner && !i.externalOwner && !i.unknownOwner,
  842. })),
  843. )
  844. /** Null when not tracked, so the card can say so rather than showing zero. */
  845. const commitmentsTracked = attended !== null
  846. // A dark key is stated on the card itself, not only in the source band at the
  847. // bottom of the page. Somebody scanning their own commitments never scrolls
  848. // that far, and a list short by one key looks exactly like a light week.
  849. const darkKeys = snap.fathomSources.filter((k) => !k.ok)
  850. const commitmentsPartial =
  851. commitmentsTracked && darkKeys.length
  852. ? `The Fathom ${plural(darkKeys.length, 'key')} for ` +
  853. `${darkKeys.map((k) => k.owner).join(' and ')} could not be read this run, so meetings ` +
  854. `they recorded are missing from this list rather than absent from the fortnight.`
  855. : null
  856. const items = [...orderItems, ...taskItems, ...todoItems, ...trackerItems].sort(
  857. (a, b) => (parseInt(b.age, 10) || 0) - (parseInt(a.age, 10) || 0),
  858. )
  859. // Named, not counted, so the empty state can say which source is dark. A list
  860. // that is short because Ninety 403'd looks exactly like a clear desk.
  861. const sourcesDown: string[] = []
  862. if (!snap.orderLines.ok) sourcesDown.push('Plinth orders')
  863. if (!snap.tasks.ok) sourcesDown.push('Bitrix tasks')
  864. if (!snap.ninety.ok) sourcesDown.push('Ninety to-dos')
  865. if (!snap.sheet.ok) sourcesDown.push('the Content Creation tracker')
  866. return {
  867. person: view,
  868. ourMove: ours.length,
  869. waiting: waiting.length,
  870. tasks: mine.length,
  871. todos: todoItems.length,
  872. tracker: trackerItems.length,
  873. // What the header counts but this card does not list. Zero on the viewer's
  874. // own card, which is the only card that is rendered today.
  875. todosWithheld: isMine ? 0 : (view.todos ?? 0),
  876. sourcesDown,
  877. items,
  878. commitments,
  879. commitmentsTracked,
  880. commitmentsPartial,
  881. // To-dos included. This drives the footer sentence about review gates,
  882. // which must not say "nothing is past a review gate" above an overdue to-do.
  883. lateCount: items.filter((i) => i.late).length,
  884. }
  885. }
  886. // --- what moved elsewhere -----------------------------------------------------
  887. export interface FeedItem {
  888. dept: string
  889. tone: 'primary' | 'accent' | 'dark' | 'hot'
  890. time: string
  891. text: string
  892. why: string
  893. /** Sort key. Null when the source records no timestamp at all. */
  894. at: number | null
  895. }
  896. const clock = (iso: string): string =>
  897. new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, hour: 'numeric', minute: '2-digit' }).format(
  898. new Date(iso),
  899. )
  900. /**
  901. * The cross-department feed Growth and Client Success never had.
  902. *
  903. * Stated on the surface itself: neither Plinth nor Bitrix keeps an event log
  904. * this can read. Everything below is inferred from timestamps that do exist (`updatedAt` on a lead, `placedAt` on an order), plus the standing
  905. * facts that are worth knowing regardless of when they changed. So it is an
  906. * honest "what looks new", not a changelog, and it must never be described as
  907. * one. When a record carries no timestamp it is listed with the time omitted
  908. * rather than with a plausible-looking one invented for it.
  909. */
  910. export function feed(snap: Snapshot, hours = 24): FeedItem[] {
  911. const since = Date.now() - hours * 3_600_000
  912. const out: FeedItem[] = []
  913. for (const l of snap.leads.data) {
  914. if (l.noise || !l.updatedAt) continue
  915. const at = Date.parse(l.updatedAt)
  916. if (!Number.isFinite(at) || at < since) continue
  917. const stage = stageName(l.statusId)
  918. const isReply = stage === 'Client Replied'
  919. out.push({
  920. dept: 'CS',
  921. tone: isReply ? 'accent' : 'primary',
  922. time: clock(l.updatedAt),
  923. text: `${l.company ?? l.title} moved to ${stage}`,
  924. why: isReply
  925. ? 'They are waiting on us. This is the one to answer first'
  926. : 'Lead pipeline movement Growth would otherwise hear about late',
  927. at,
  928. })
  929. }
  930. for (const l of snap.orderLines.data) {
  931. if (!l.placedAt) continue
  932. const at = Date.parse(l.placedAt)
  933. if (!Number.isFinite(at) || at < since) continue
  934. out.push({
  935. dept: 'Ops',
  936. tone: 'dark',
  937. time: clock(l.placedAt),
  938. text: `New order ${l.orderId}: ${l.company ?? l.customer ?? 'customer'}`,
  939. why: `${l.productTitle || 'order line'} · now in ${l.productStatus}`,
  940. at,
  941. })
  942. }
  943. const exceptions = snap.orderLines.data.filter((l) => EXCEPTION_STATUSES.test(l.orderStatus))
  944. const unowned = exceptions.filter((l) => !l.productionOwner && !l.accountOwner)
  945. if (unowned.length) {
  946. const value = unowned.reduce((s, l) => s + (l.amount || 0), 0)
  947. out.push({
  948. dept: 'Ops',
  949. tone: 'dark',
  950. time: '–',
  951. text: `${unowned.length} exception${unowned.length === 1 ? '' : 's'} logged with no owner`,
  952. why: value ? `$${value.toLocaleString('en-US')} not captured` : 'Each needs a decision, not a task',
  953. at: null,
  954. })
  955. }
  956. // Assignments arrive on the API, so the failure worth reporting is the
  957. // fields not arriving, not staleness.
  958. const assignCov = snap.orderWindow.assignmentFields
  959. if (
  960. snap.mode === 'live' &&
  961. snap.orderLines.ok &&
  962. assignCov.total > 0 &&
  963. assignCov.production === 0 &&
  964. assignCov.accountOwner === 0
  965. ) {
  966. out.push({
  967. dept: 'Ops',
  968. tone: 'hot',
  969. time: '–',
  970. text: 'Plinth stopped returning the assignment fields',
  971. why: 'Per-person order counts are blind: missing, not zero',
  972. at: null,
  973. })
  974. }
  975. // Timestamped items first, newest to oldest; standing facts settle underneath.
  976. return out.sort((a, b) => (b.at ?? -1) - (a.at ?? -1)).slice(0, 14)
  977. }
  978. // --- coverage matrix ----------------------------------------------------------
  979. export interface CoverageCell {
  980. text: string
  981. color: string
  982. }
  983. export interface CoverageRow {
  984. name: string
  985. cells: CoverageCell[]
  986. }
  987. export const COVERAGE_COLUMNS = [
  988. 'Plinth design',
  989. 'Plinth client success',
  990. 'Bitrix tasks',
  991. 'Ninety to-dos',
  992. ] as const
  993. /**
  994. * Ninety is the one source on this board that covers everyone.
  995. *
  996. * Plinth only sees people holding order lines, and Bitrix tasks only sees
  997. * people with tasks, so anyone with neither is structurally invisible to them.
  998. * `GET /todos/company` is company-scoped by design, so when it is up, every
  999. * person is genuinely covered by it.
  1000. *
  1001. * Note what "covered" means in this table: the source can see this person, not
  1002. * that the person has work. Somebody with zero open to-dos is covered and idle,
  1003. * which is a different and much less alarming thing than untracked.
  1004. */
  1005. export function coverage(snap: Snapshot): CoverageRow[] {
  1006. // No category hues here: 'not tracked' in orange would read as something
  1007. // about Bitrix tasks, and 'source down' in magenta as something about clients. The
  1008. // cell kind drives a texture in the surface instead.
  1009. const covered: CoverageCell = { text: 'covered', color: C.ink }
  1010. const na: CoverageCell = { text: '–', color: C.gray }
  1011. const gap: CoverageCell = { text: 'not tracked', color: C.ink }
  1012. const down: CoverageCell = { text: 'source down', color: C.ink }
  1013. return MERIDIAN_PEOPLE.map((p) => {
  1014. const hasPlinth = p.plinthAccounts.length > 0
  1015. const design = p.coveredBy.includes('plinth-designer')
  1016. const cs = p.coveredBy.includes('plinth-client-success')
  1017. return {
  1018. name: p.name,
  1019. cells: [
  1020. design ? covered : hasPlinth ? na : gap,
  1021. cs ? covered : hasPlinth ? na : gap,
  1022. p.coveredBy.includes('bitrix-tasks') ? covered : gap,
  1023. snap.ninety.ok ? covered : down,
  1024. ],
  1025. }
  1026. })
  1027. }
  1028. // --- briefing -----------------------------------------------------------------
  1029. export interface BriefingDelta {
  1030. label: string
  1031. value: number
  1032. delta: Delta | null
  1033. color: string
  1034. /** False when the source behind the number did not answer. Render a dash. */
  1035. available: boolean
  1036. }
  1037. export interface Briefing {
  1038. /** America/Chicago, e.g. "Wednesday, August 12". */
  1039. dateLong: string
  1040. dateShort: string
  1041. generatedAt: string
  1042. liveSources: number
  1043. totalSources: number
  1044. headlineDeltas: BriefingDelta[]
  1045. answerFirst: DrillRow[]
  1046. loadedSentences: string[]
  1047. blindSpots: string[]
  1048. /** Null when there is no earlier run to compare with. */
  1049. comparedTo: string | null
  1050. }
  1051. const fmtLong = (d: Date) =>
  1052. new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, weekday: 'long', month: 'long', day: 'numeric' }).format(d)
  1053. const fmtShort = (d: Date) =>
  1054. new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, weekday: 'short', month: 'short', day: 'numeric' }).format(d)
  1055. /**
  1056. * The prose briefing's sentences, built from the same numbers as the board.
  1057. *
  1058. * Every clause here is derived, never templated over an assumption. In
  1059. * particular the workload sentence says "past a review gate" and not "days
  1060. * overdue", because at Meridian nothing closes outside a Monday review. A briefing
  1061. * that says "Sam is 15 days overdue" about finished work says it in front of
  1062. * the whole team.
  1063. */
  1064. export function briefing(
  1065. snap: Snapshot,
  1066. h: Headline,
  1067. people: PersonView[],
  1068. board: PeopleBoard,
  1069. history: HistoryRow[],
  1070. today: string,
  1071. ): Briefing {
  1072. const now = new Date(snap.syncedAt)
  1073. const prev = previousRun(history, today)
  1074. const sources = sourceStatuses(snap)
  1075. const d = drills(snap)
  1076. const ordersOk = snap.orderLines.ok
  1077. const bothOk = ordersOk && snap.leads.ok
  1078. const headlineDeltas: BriefingDelta[] = [
  1079. {
  1080. label: 'Our move',
  1081. value: h.ourMove,
  1082. delta: bothOk ? delta(h.ourMove, prev, 'ourMove') : null,
  1083. color: C.ink,
  1084. available: bothOk,
  1085. },
  1086. {
  1087. label: 'Design queue',
  1088. value: h.designQueue,
  1089. delta: ordersOk ? delta(h.designQueue, prev, 'designQueue') : null,
  1090. color: C.blue,
  1091. available: ordersOk,
  1092. },
  1093. ]
  1094. const tracked = people.filter((p) => p.load !== null)
  1095. const heaviest = [...tracked].sort((a, b) => (b.load ?? 0) - (a.load ?? 0))
  1096. const untracked = people.filter((p) => p.plinthOurMove === null)
  1097. const loadedSentences: string[] = []
  1098. const top = heaviest[0]
  1099. if (top) {
  1100. const oldest = top.oldestDays !== null ? ` and the oldest has been with them ${top.oldestDays} days` : ''
  1101. loadedSentences.push(
  1102. `${top.name} is carrying ${top.load} open item${top.load === 1 ? '' : 's'}${oldest}. That is the heaviest board on the team.`,
  1103. )
  1104. }
  1105. for (const p of heaviest.slice(1, 4)) {
  1106. const bits: string[] = [`${p.name} is at ${p.load}`]
  1107. if (p.actuallyLate) {
  1108. bits.push(`${p.actuallyLate} of them past a Monday review gate, which is what late means here`)
  1109. }
  1110. if (p.plinthWaiting) {
  1111. bits.push(
  1112. `${p.plinthWaiting} more ${p.plinthWaiting === 1 ? 'sits' : 'sit'} with the client and ${p.plinthWaiting === 1 ? 'is' : 'are'} not on their board today`,
  1113. )
  1114. }
  1115. loadedSentences.push(`${bits.join(', ')}.`)
  1116. }
  1117. if (untracked.length) {
  1118. loadedSentences.push(
  1119. `${untracked.map((p) => p.short).join(' and ')} hold no Plinth assignment, so whatever they are doing is invisible to this. Do not read their small numbers as spare capacity.`,
  1120. )
  1121. }
  1122. const blindSpots: string[] = []
  1123. for (const s of sources) {
  1124. if (s.tone === 'failed') blindSpots.push(`${s.name} failed this morning, so its numbers are missing rather than zero.`)
  1125. if (s.tone === 'absent') blindSpots.push(`${s.name} is ${s.status}, so nothing it covers is in this.`)
  1126. // "Name: status." rather than a sentence built around the status: statuses
  1127. // are sometimes phrases ("92% of lines") and sometimes single words
  1128. // ("partial"), and a sentence that reads like a typo gets skimmed past,
  1129. // which a blind-spot line cannot afford.
  1130. if (s.tone === 'degraded') blindSpots.push(`${s.name}: ${s.status}. Treat those figures as indicative.`)
  1131. }
  1132. return {
  1133. dateLong: fmtLong(now),
  1134. dateShort: fmtShort(now),
  1135. generatedAt: new Intl.DateTimeFormat('en-US', {
  1136. timeZone: MERIDIAN_TZ,
  1137. hour: 'numeric',
  1138. minute: '2-digit',
  1139. }).format(now),
  1140. liveSources: sources.filter((s) => s.tone === 'live').length,
  1141. totalSources: sources.length,
  1142. headlineDeltas,
  1143. // Client-replied first, then unowned exceptions: both are somebody waiting.
  1144. answerFirst: [...(d.replied?.rows ?? []), ...(d.exceptions?.rows ?? [])].slice(0, 6),
  1145. loadedSentences,
  1146. blindSpots,
  1147. comparedTo: prev?.day ?? null,
  1148. }
  1149. }
  1150. // --- shared chart rows ---------------------------------------------------------
  1151. export interface StageBar extends StageCount {
  1152. pct: string
  1153. color: string
  1154. }
  1155. export function stageBars(rows: StageCount[]): StageBar[] {
  1156. const max = Math.max(1, ...rows.map((r) => r.count))
  1157. return rows.map((r) => ({
  1158. ...r,
  1159. pct: `${Math.round((r.count / max) * 100)}%`,
  1160. color: CLASS_COLOR[r.class] ?? C.gray,
  1161. }))
  1162. }
  1163. export interface LeadRow {
  1164. age: number
  1165. ageColor: string
  1166. stage: string
  1167. title: string
  1168. company: string | null
  1169. }
  1170. /**
  1171. * Parked leads are excluded on purpose: they are paused deliberately, so they
  1172. * are not aging in any sense that should worry anyone, and including them buries
  1173. * the ones that are.
  1174. */
  1175. export function oldestLeads(leads: Lead[], limit = 10): LeadRow[] {
  1176. return agedLeads(leads)
  1177. // Terminal is not open. A completed lead listed under "Oldest open leads"
  1178. // is a finished job presented as the oldest thing rotting in the pipeline,
  1179. // so both parked and terminal leads are excluded.
  1180. .filter((l) => {
  1181. const c = classifyLead(l.statusId, l.semantic)
  1182. return c !== 'parked' && c !== 'terminal'
  1183. })
  1184. .slice(0, limit)
  1185. .map((l) => ({
  1186. age: l.ageDays,
  1187. ageColor: l.ageDays >= 60 ? C.magenta : C.ink,
  1188. stage: stageName(l.statusId),
  1189. title: l.title,
  1190. company: l.company,
  1191. }))
  1192. }
  1193. // --- the queue: order numbers, and who is holding them ----------------------
  1194. /**
  1195. * The panel people asked for by name.
  1196. *
  1197. * A headline number tells leadership the shape of the day. It tells a designer
  1198. * nothing they can act on. This is the other thing: a pasteable order number, the product, how
  1199. * long it has sat, and whose desk it is on.
  1200. *
  1201. * Every string here is pre-formatted for display, including the honest absences.
  1202. * "not recorded" and "unassigned" are values, not empty cells. A blank in a
  1203. * designer column reads as "no designer needed", which is the opposite of what
  1204. * an unassigned order means.
  1205. */
  1206. export interface QueueRowView {
  1207. orderNumber: string
  1208. product: string
  1209. status: string
  1210. statusColor: string
  1211. customer: string
  1212. age: string
  1213. ageDays: number | null
  1214. /** True when this row is genuinely old and should be visually weighted. */
  1215. aging: boolean
  1216. designQueue: boolean
  1217. }
  1218. export interface QueueGroupView {
  1219. /** The designer's name, or "Unassigned". Never blank. */
  1220. designer: string
  1221. unassigned: boolean
  1222. count: number
  1223. /** "3 orders · oldest 46 days" */
  1224. summary: string
  1225. rows: QueueRowView[]
  1226. }
  1227. export interface QueueView {
  1228. available: boolean
  1229. /** Why it is empty or partial, written for a person. */
  1230. reason: string
  1231. total: number
  1232. groups: QueueGroupView[]
  1233. /** Orders nobody is assigned to. A data gap, called that, not a queue. */
  1234. unassignedCount: number
  1235. /**
  1236. * Set when almost everything is old, in which case the per-row badges are
  1237. * suppressed and this sentence replaces them. Null when aging is genuinely
  1238. * the exception and the badges carry information.
  1239. */
  1240. everythingIsOld: string | null
  1241. }
  1242. /** Anything sitting longer than this gets visual weight in the queue. */
  1243. const AGING_DAYS = 14
  1244. /**
  1245. * Above this share, the flag stops being an exception and becomes the weather.
  1246. *
  1247. * A badge on 13 of 13 rows is not information; it is a background colour that
  1248. * teaches people to stop seeing the word. When nearly everything trips the
  1249. * threshold, the honest move is to say it once, in a sentence, and drop the
  1250. * badges entirely.
  1251. */
  1252. const FLAG_IS_MEANINGLESS_ABOVE = 0.6
  1253. function queueRowView(r: OrderQueueRow, showAgingFlags: boolean): QueueRowView {
  1254. const text = (v: string | null, absent: string) => (v && v.trim() ? v : absent)
  1255. return {
  1256. orderNumber: r.orderNumber,
  1257. // A null product is the export not carrying one. Say that rather than
  1258. // echoing the order number back into the column beside itself.
  1259. product: text(r.product, 'product not in export'),
  1260. status: r.status,
  1261. statusColor: CLASS_COLOR[classifyOrder(r.status) as keyof typeof CLASS_COLOR] ?? C.gray,
  1262. // Same trap as product: an order-level export has no customer name, and
  1263. // falling back to the order number would make three columns in a row say
  1264. // the same thing.
  1265. customer: text(r.company ?? r.customer, 'client not in export'),
  1266. age: r.ageDays === null ? 'age unknown' : `${r.ageDays}d`,
  1267. /** Raw days for the 60-day age track; null renders no track at all. */
  1268. ageDays: r.ageDays,
  1269. aging: showAgingFlags && (r.ageDays ?? 0) >= AGING_DAYS,
  1270. designQueue: r.designQueue,
  1271. }
  1272. }
  1273. export function queueView(snap: Snapshot): QueueView {
  1274. if (!snap.orderLines.ok) {
  1275. return {
  1276. available: false,
  1277. reason: `Plinth is not answering, so there is no queue to show. This is not an empty queue: ${snap.orderLines.error}`,
  1278. total: 0,
  1279. groups: [],
  1280. unassignedCount: 0,
  1281. everythingIsOld: null,
  1282. }
  1283. }
  1284. const groups = orderQueueByDesigner(snap.orderLines.data)
  1285. const total = groups.reduce((n, g) => n + g.rows.length, 0)
  1286. const unassignedCount = groups.find((g) => g.designer === null)?.rows.length ?? 0
  1287. const allRows = groups.flatMap((g) => g.rows)
  1288. const old = allRows.filter((r) => (r.ageDays ?? 0) >= AGING_DAYS)
  1289. const saturated = total > 0 && old.length / total > FLAG_IS_MEANINGLESS_ABOVE
  1290. const ages = allRows.map((r) => r.ageDays).filter((d): d is number => d !== null)
  1291. const median = ages.length ? [...ages].sort((a, b) => a - b)[Math.floor(ages.length / 2)]! : null
  1292. return {
  1293. available: true,
  1294. reason: unassignedCount
  1295. ? snap.orderWindow.assignmentFields.production === 0 &&
  1296. snap.orderWindow.assignmentFields.total > 0
  1297. ? `${unassignedCount} of ${total} order lines carry no designer, but Plinth returned no production_owner field at all this run, so that is a coverage gap, not an empty desk.`
  1298. : `${unassignedCount} of ${total} order lines are unassigned in Plinth. Nobody is holding them.`
  1299. : `${total} order lines, all assigned.`,
  1300. total,
  1301. unassignedCount,
  1302. // Said once instead of stamped on every row. A badge that fires on
  1303. // everything stops being read by the second week.
  1304. everythingIsOld: saturated
  1305. ? `${old.length} of ${total} lines are over ${AGING_DAYS} days old` +
  1306. (median === null ? '' : `, median ${median} days`) +
  1307. `. Age is not the exception here, so the per-row markers are turned off; ` +
  1308. `sort by the Age column instead.`
  1309. : null,
  1310. groups: groups.map((g) => {
  1311. const ages = g.rows.map((r) => r.ageDays).filter((d): d is number => d !== null)
  1312. const oldest = ages.length ? Math.max(...ages) : null
  1313. return {
  1314. designer: g.designer ?? 'Unassigned',
  1315. unassigned: g.designer === null,
  1316. count: g.rows.length,
  1317. summary:
  1318. `${g.rows.length} ${plural(g.rows.length, 'order line')}` +
  1319. (oldest === null ? '' : ` · oldest ${oldest} ${plural(oldest, 'day')}`),
  1320. rows: g.rows.map((r) => queueRowView(r, !saturated)),
  1321. }
  1322. }),
  1323. }
  1324. }
  1325. export interface ViewModel {
  1326. snap: Snapshot
  1327. headline: Headline
  1328. board: PeopleBoard
  1329. people: PersonView[]
  1330. peopleAll: PersonView[]
  1331. drills: Record<string, Drill>
  1332. sources: SourceStatus[]
  1333. orderStages: StageBar[]
  1334. leads: LeadRow[]
  1335. feed: FeedItem[]
  1336. coverage: CoverageRow[]
  1337. briefing: Briefing
  1338. myDays: MyDay[]
  1339. queue: QueueView
  1340. caveats: string[]
  1341. caveatRows: { src: string; text: string }[]
  1342. failures: string[]
  1343. }
  1344. export function buildViewModel(
  1345. snap: Snapshot,
  1346. headline: Headline,
  1347. history: HistoryRow[],
  1348. today: string,
  1349. /**
  1350. * Who this build is for. Only My Day reads it, and only to decide whose to-do
  1351. * titles it may list. Defaults to the viewer who owns nothing, so a caller that
  1352. * forgets the argument discloses nothing.
  1353. */
  1354. viewer: Viewer = COMPANY_VIEWER,
  1355. ): ViewModel {
  1356. const board = peopleBoard(snap)
  1357. const people = personViews(board, snap.tasks.data, 'ours')
  1358. const peopleAll = personViews(board, snap.tasks.data, 'all')
  1359. const failures: string[] = []
  1360. if (!snap.leads.ok) failures.push(`Bitrix leads failed: ${snap.leads.error}`)
  1361. if (!snap.orderLines.ok) failures.push(`Plinth orders failed: ${snap.orderLines.error}`)
  1362. if (!snap.tasks.ok) failures.push(`Bitrix tasks failed: ${snap.tasks.error}`)
  1363. if (!snap.sheet.ok) {
  1364. failures.push(
  1365. `Content Creation tracker failed: ${snap.sheet.error}. A 401 or 403 means the ` +
  1366. `sheet's link sharing was tightened. That public read is how this source ` +
  1367. `works at all.`,
  1368. )
  1369. }
  1370. // No appended guess (rule 1). The client's errors already say what
  1371. // happened, and the 403 carries its endpoint-specific explanation itself;
  1372. // a fixed sentence appended here would put a 403's explanation on a 429.
  1373. if (!snap.ninety.ok) failures.push(`Ninety to-dos failed: ${snap.ninety.error}`)
  1374. const caveats: string[] = [...snap.assignmentWarnings]
  1375. // A truncated sweep is a caveat on every order number on this page, so it
  1376. // goes first. Plinth pages by product line and reports no total, so nothing
  1377. // in the rows themselves reveals that the window was cut short.
  1378. if (snap.orderWindow.truncated) {
  1379. caveats.push(
  1380. `The Plinth sweep hit its page cap, so every order count here is a FLOOR rather ` +
  1381. `than a total: there are more orders in the window than this page can see. ` +
  1382. `Narrow the window or raise the page cap before acting on an order number.`,
  1383. )
  1384. }
  1385. // Same for leads: on the page, not only in a terminal.
  1386. if (snap.leadWindow.truncated) {
  1387. caveats.push(
  1388. `The Bitrix lead sweep hit its page cap, so the open-lead count is a FLOOR ` +
  1389. `rather than a total.`,
  1390. )
  1391. }
  1392. if (headline.unknownStatuses.length) {
  1393. caveats.push(
  1394. `${headline.unknownStatuses.length} product status${headline.unknownStatuses.length === 1 ? ' is' : 'es are'} unclassified and counted as our move: ${headline.unknownStatuses.join(', ')}.`,
  1395. )
  1396. }
  1397. // A partial sheet read is a caveat on every tracker number on this page. The
  1398. // per-tab loop reports what it got rather than throwing, which is right: three
  1399. // tabs of four is most of the picture. But "most" has to be said out loud, or
  1400. // the counts read as complete.
  1401. if (snap.sheetTabs.failed.length) {
  1402. caveats.push(
  1403. `The Content Creation tracker was read only in part: ` +
  1404. `${snap.sheetTabs.failed.map((f) => `${f.label} (${f.reason})`).join('; ')}. ` +
  1405. `Every tracker count on this page is a FLOOR, not a total.`,
  1406. )
  1407. }
  1408. const tc = trackerCounts(snap)
  1409. if (snap.sheet.ok && tc.unclassified) {
  1410. caveats.push(
  1411. `${tc.unclassified} Content Creation tracker row${tc.unclassified === 1 ? '' : 's'} ` +
  1412. `${tc.unclassified === 1 ? 'has' : 'have'} a Status of ${tc.unknownStatuses.join(', ')}, ` +
  1413. `which nothing classifies, counted as neither open nor done rather than assumed either way.`,
  1414. )
  1415. }
  1416. if (snap.sheet.ok && tc.unmappedOwners.length) {
  1417. caveats.push(
  1418. `Content Creation tracker owner${tc.unmappedOwners.length === 1 ? '' : 's'} ` +
  1419. `${tc.unmappedOwners.join(', ')} match nobody on the roster, so those rows appear on no one's board.`,
  1420. )
  1421. }
  1422. caveats.push(
  1423. snap.fathomConfigured
  1424. ? 'Meeting commitments show only the meetings you were in. Someone else\u2019s page shows theirs, and neither page lists the other\u2019s.'
  1425. : 'Fathom is not configured, so meeting commitments read as not tracked rather than zero.',
  1426. )
  1427. /**
  1428. * The caveats again, labelled by source for the redesigned band: one row per
  1429. * note with the source named in the left column, instead of one paragraph
  1430. * joined by dots. Same strings, so nothing asserted on vm.caveats moves.
  1431. */
  1432. const labelFor = (text: string): string => {
  1433. if (/Plinth sweep/i.test(text)) return 'Plinth orders'
  1434. if (/lead sweep/i.test(text)) return 'Bitrix leads'
  1435. if (/tracker/i.test(text)) return 'Content tracker'
  1436. if (/Fathom/i.test(text)) return 'Fathom'
  1437. if (/product status/i.test(text)) return 'Plinth statuses'
  1438. if (/[Aa]ssignment/.test(text)) return 'Plinth assignments'
  1439. return 'Note'
  1440. }
  1441. const caveatRows = caveats.map((text) => ({ src: labelFor(text), text }))
  1442. return {
  1443. snap,
  1444. headline,
  1445. board,
  1446. people,
  1447. peopleAll,
  1448. drills: drills(snap),
  1449. sources: sourceStatuses(snap),
  1450. orderStages: stageBars(productStages(snap.orderLines.data)),
  1451. leads: oldestLeads(snap.leads.data),
  1452. feed: feed(snap),
  1453. coverage: coverage(snap),
  1454. briefing: briefing(snap, headline, people, board, history, today),
  1455. myDays: people.map((p) => myDay(snap, p, viewer)),
  1456. queue: queueView(snap),
  1457. caveats,
  1458. caveatRows,
  1459. failures,
  1460. }
  1461. }