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

main.ts

Language TypeScript15.5 KB309 lines

apps/dashboard/src/main.ts

  1. #!/usr/bin/env tsx
  2. /**
  3. * Build the dashboard.
  4. *
  5. * pnpm dashboard live, using BITRIX_WEBHOOK_URL and PLINTH_API_KEY
  6. * pnpm dashboard --fixtures sample data, no credentials needed
  7. * pnpm dashboard --days 90 widen the Plinth order window (default 60)
  8. * pnpm dashboard --render-only re-render the last sync, no API calls at all
  9. *
  10. * Writes dashboard.html in the repo root and prints a summary. Read-only
  11. * throughout: no write method can be reached from here.
  12. */
  13. import { localStamp } from '@meridian/ninety-client'
  14. import { writeFile } from 'node:fs/promises'
  15. import { collect, headline, type Snapshot } from './collect.js'
  16. import { classifyOrder } from '@meridian/plinth-client'
  17. import { LEAD_STAGES, stageName } from '../../../config/bitrix-stages.js'
  18. import { render } from './render.js'
  19. import { FIXTURE_SNAPSHOT } from './fixtures.js'
  20. import { rowFrom } from './history.js'
  21. import { readHistory, recordRun } from './history-file.js'
  22. import { buildBriefingPayload } from './briefing-payload.js'
  23. import { readBriefings } from './briefings-file.js'
  24. import { readSnapshot, writeSnapshot, SNAPSHOT_FILE } from './snapshot-file.js'
  25. import { errorMessage } from '@meridian/shared'
  26. const args = process.argv.slice(2)
  27. const useFixtures = args.includes('--fixtures')
  28. /**
  29. * Re-render the page from the cached snapshot instead of syncing.
  30. *
  31. * This is the second half of `pnpm morning`. The briefings are written from one
  32. * sync's numbers, and the page then has to be rebuilt to show them. Rebuilding
  33. * with a fresh sync would move `syncedAt`, so the prose and the numbers would no
  34. * longer describe the same moment and every clean run would call its own output
  35. * stale. Re-rendering the same sync keeps that warning meaningful (see
  36. * snapshot-file.ts).
  37. *
  38. * Needs no credentials, because it makes no requests.
  39. */
  40. const renderOnly = args.includes('--render-only')
  41. const days = args.includes('--days') ? Number(args[args.indexOf('--days') + 1]) || 60 : 60
  42. // Fixture runs must not pollute the real day-over-day history; a demo would
  43. // otherwise become "yesterday" for tomorrow's briefing.
  44. const historyAllowed = !useFixtures && !args.includes('--no-history')
  45. /**
  46. * Credentials come from the process environment only.
  47. *
  48. * There is no `.env` reader anywhere in this repo, on purpose. A credentials
  49. * file in the working tree is readable by every tool and agent that touches the
  50. * repo, and a secret that has been read is a secret that has to be rotated.
  51. * Exporting from the shell profile keeps them out of the tree entirely.
  52. *
  53. * The trade is that a new terminal must have sourced the profile, and that is
  54. * the failure this message exists to name: when nothing is set, the problem is
  55. * almost always the shell, not the repo. Saying "not set" without saying where
  56. * to look sends people hunting through code for a loader that does not exist.
  57. */
  58. function preflight(): void {
  59. const required = ['NINETY_TOKEN', 'BITRIX_WEBHOOK_URL', 'PLINTH_API_KEY'] as const
  60. const missing = required.filter((k) => !process.env[k])
  61. if (!missing.length) return
  62. console.error(` Missing from the environment: ${missing.join(', ')}`)
  63. if (missing.length === required.length) {
  64. console.error(` ALL of them are missing, which usually means this shell has not`)
  65. console.error(` sourced your profile: a new terminal, or an editor-spawned one.`)
  66. } else {
  67. console.error(` ${required.length - missing.length} other credential(s) ARE set, so the profile did load.`)
  68. console.error(` The ones above are absent from it, or spelled differently there.`)
  69. }
  70. console.error(`\n Credentials live in the shell profile; this repo has no .env reader.`)
  71. console.error(` See what the shell actually has:`)
  72. console.error(` env | grep -E 'NINETY|BITRIX|PLINTH_|ANTHROPIC'`)
  73. console.error(` Then open a new terminal, or re-source the profile:`)
  74. console.error(` source ~/.zshrc # or whichever profile you use\n`)
  75. }
  76. /** Order lines the team owes a move on, excluding leads. */
  77. function orderOurMove(snap: Snapshot): number {
  78. return snap.orderLines.data.filter(
  79. (l) => classifyOrder(l.orderStatus, l.orderStatusId || null) === 'ours',
  80. ).length
  81. }
  82. async function main(): Promise<void> {
  83. let snap: Snapshot
  84. if (renderOnly) {
  85. const cached = await readSnapshot()
  86. if (!cached.ok) {
  87. // Fail rather than fall back to a live sync. A silent fallback is how this
  88. // flag would quietly stop doing its job: the page would look right and the
  89. // staleness warning would be back, with nothing saying the cache was gone.
  90. console.error(`Cannot re-render: ${cached.reason}.`)
  91. console.error(` ${decodeURIComponent(SNAPSHOT_FILE.pathname)}`)
  92. if (cached.missing) {
  93. console.error(`--render-only re-renders the last sync, and there has not been one.`)
  94. } else {
  95. console.error(`There is a file there but it is not a usable snapshot. Refusing to`)
  96. console.error(`render half a page rather than rendering one that looks complete.`)
  97. }
  98. console.error(`Run \`pnpm dashboard\` first, or \`pnpm morning\` for the whole flow.`)
  99. process.exit(1)
  100. }
  101. snap = cached.snap
  102. console.log(`Re-rendering the sync from ${localStamp(snap.syncedAt)} CT. No API calls.\n`)
  103. } else if (useFixtures) {
  104. console.log('Fixture mode: no API calls.\n')
  105. snap = FIXTURE_SNAPSHOT
  106. } else {
  107. preflight()
  108. console.log(`Pulling Bitrix leads and Plinth order lines (last ${days} days)...\n`)
  109. // The file source is passed explicitly: collect.ts is runtime-neutral so it
  110. // can also run in the Worker, which has no filesystem.
  111. snap = await collect(process.env, days)
  112. }
  113. const h = headline(snap)
  114. const today = rowFrom(h, new Date(snap.syncedAt))
  115. // A run where a source died reports zeros, and a zero recorded today becomes
  116. // tomorrow's "yesterday", so the morning after an outage the briefing would
  117. // announce that the design queue grew by 37 overnight. It also must not show a
  118. // delta of its own: "down 146" is not news, it is the outage. So a broken run
  119. // neither writes history nor reads it.
  120. const sourcesOk = snap.leads.ok && snap.orderLines.ok && snap.tasks.ok
  121. // Read off the snapshot, not off the flag: on a --render-only run the flag is
  122. // absent but the cached sync may still be fixture data.
  123. const isFixtures = snap.mode === 'fixtures'
  124. const keepHistory = historyAllowed && sourcesOk && !renderOnly
  125. // Record first, then render against what was on file before this run;
  126. // otherwise the page would diff today against itself and every delta is zero.
  127. //
  128. // A --render-only run reads but never records: the row for today was written by
  129. // the sync it is re-rendering, and writing it again would be the second half of
  130. // `pnpm morning` overwriting the first. Reading back the full file including
  131. // today is safe because `previousRun()` filters to `r.day < today`, so today's
  132. // own row cannot become its own comparison, and nothing else on the page
  133. // iterates the array.
  134. const history = !sourcesOk || isFixtures ? [] : keepHistory ? await recordRun(today) : await readHistory()
  135. // Written briefings from the previous brief.py run, if any. Rendering them is
  136. // what makes the model output visible on the board instead of only in a
  137. // terminal. See briefings-file.ts for why the chain is file-mediated.
  138. const briefings = await readBriefings()
  139. const html = render(snap, h, history, today.day, briefings)
  140. const out = new URL('../../../dashboard.html', import.meta.url)
  141. await writeFile(out, html, 'utf8')
  142. // Cache the sync so `--render-only` can rebuild this exact page later. Not on
  143. // a --render-only run, which would only rewrite what it just read.
  144. if (!renderOnly) await writeSnapshot(snap)
  145. // The model-facing payload. Written on every run, including fixture runs:
  146. // a consumer needs something to develop against without credentials. It is a
  147. // separate file from the HTML on purpose: the page is for people, this is for
  148. // whatever generates the analysis, and neither should have to parse the other.
  149. //
  150. // Not rewritten on a --render-only run. `generatedAt` is `snap.syncedAt`, so
  151. // rewriting it from the cached snapshot would produce a byte-identical file,
  152. // and "render only" should mean what it says. The existing payload is already
  153. // the one the briefings were written from.
  154. const payload = buildBriefingPayload(snap)
  155. const payloadOut = new URL('../../../briefing-payload.json', import.meta.url)
  156. if (!renderOnly) {
  157. await writeFile(payloadOut, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
  158. }
  159. const row = (k: string, v: string | number) => console.log(` ${k.padEnd(22)}${v}`)
  160. // Split, because one number covering both is unreadable: leads and order
  161. // lines are different kinds of work, with different owners and urgency, and
  162. // their sum is a figure nobody can act on.
  163. const leadOurs = h.ourMove - orderOurMove(snap)
  164. row('our move', `${h.ourMove} (${leadOurs} leads + ${orderOurMove(snap)} order lines)`)
  165. row('design queue', h.designQueue)
  166. row('client replied', h.needsReply)
  167. row('waiting on client', h.waitingOnClient)
  168. row('at vendor / transit', h.atVendor)
  169. // Three numbers, never one. Open leads can be mostly finished work, and the
  170. // excluded counts are shown rather than subtracted, because a number that
  171. // quietly shrinks is indistinguishable from one that is wrong.
  172. const excluded = [
  173. h.completedLeads ? `${h.completedLeads} completed` : null,
  174. h.noiseLeads ? `${h.noiseLeads} automated` : null,
  175. ].filter(Boolean)
  176. row(
  177. 'open leads',
  178. `${h.realLeads} still open` + (excluded.length ? ` (excluding ${excluded.join(' and ')})` : ''),
  179. )
  180. // Commitments and assignments, never one alone. A bare row count overstates
  181. // the work whenever an item is carried by more than one person.
  182. row(
  183. 'commitments',
  184. h.commitments === null
  185. ? 'not tracked (Ninety unavailable)'
  186. : `${h.commitments} across ${h.assignments} assignment(s)` +
  187. (h.sharedCommitments ? `, ${h.sharedCommitments} shared` : ''),
  188. )
  189. row('to-dos past a review', h.lateTodos === null ? 'not tracked' : h.lateTodos)
  190. // Shape diagnostics. A small number is not automatically a wrong number (a
  191. // 60-day window is mostly finished orders), but "4 our move" is unreadable
  192. // without knowing whether 4 orders came back or 400. Print the denominator.
  193. if (snap.orderLines.ok) {
  194. const lines = snap.orderLines.data
  195. const orders = new Set(lines.map((l) => l.orderId)).size
  196. console.log(`\n Plinth: ${orders} orders, ${lines.length} product lines in the last ${days} days`)
  197. const byClass = new Map<string, number>()
  198. for (const l of lines) {
  199. const c = classifyOrder(l.orderStatus, l.orderStatusId || null)
  200. byClass.set(c, (byClass.get(c) ?? 0) + 1)
  201. }
  202. console.log(` ${[...byClass].map(([c, n]) => `${c}=${n}`).join(' ') || '(none)'}`)
  203. console.log(` fetched over ${snap.orderWindow.pages} page(s)`)
  204. // Compared in lines, because the cap is in lines: an order count against a
  205. // line limit could never fire (100 lines is about 60 orders).
  206. if (snap.orderWindow.truncated) {
  207. console.error(` TRUNCATED at the page cap: these counts are a FLOOR, not a total.`)
  208. console.error(` Narrow --days or raise maxPages before trusting any order number.`)
  209. }
  210. }
  211. if (snap.leads.ok) {
  212. const w = snap.leadWindow
  213. const real = snap.leads.data.filter((l) => !l.noise)
  214. console.log(` Bitrix leads: ${snap.leads.data.length} open` +
  215. (w.sinceDays === null ? ' (no date floor)' : `, created in the last ${w.sinceDays} days`) +
  216. ` over ${w.pages} page(s)`)
  217. if (w.truncated) {
  218. console.error(` TRUNCATED at the page cap: this count is a FLOOR, not a total.`)
  219. console.error(` Narrow the window or raise maxPages before trusting it.`)
  220. }
  221. // Stage breakdown, because "our move: 212" is unreadable without it. If one
  222. // stage holds almost all of them, that stage is the story, and if that
  223. // stage is New/Unassigned, the story is automation pollution rather than
  224. // work, since that is where email-captured leads land.
  225. const byStage = new Map<string, number>()
  226. for (const l of real) {
  227. const name = stageName(l.statusId) ?? l.statusId
  228. byStage.set(name, (byStage.get(name) ?? 0) + 1)
  229. }
  230. const top = [...byStage].sort((a, b) => b[1] - a[1]).slice(0, 8)
  231. console.log(` ${real.length} after the noise filter, by stage:`)
  232. for (const [name, n] of top) {
  233. const stage = LEAD_STAGES.find((s) => s.name === name || s.id === name)
  234. const cls = stage ? stage.class : 'UNCLASSIFIED -> counted as ours'
  235. console.log(` ${String(n).padStart(4)} ${name} (${cls})`)
  236. }
  237. if (h.unknownLeadStages.length) {
  238. const share = Math.round((h.ourMoveUnclassified / Math.max(h.ourMove, 1)) * 100)
  239. console.error(``)
  240. console.error(` UNCLASSIFIED LEAD STAGES: ${h.ourMoveUnclassified} of "our move" (${share}%) is unclassified.`)
  241. console.error(` config/bitrix-stages.ts was written from a small lead sample. The real`)
  242. console.error(` pipeline mostly lives in stages it has never seen, and every unknown`)
  243. console.error(` stage defaults to "ours", which is why the headline reads the way it does.`)
  244. console.error(` Until these are classified, "our move" is not a workload number.`)
  245. for (const u of h.unknownLeadStages.slice(0, 10)) {
  246. console.error(` ${String(u.count).padStart(4)} ${u.id}`)
  247. }
  248. console.error(` Add each to LEAD_STAGES with the right class. "JUNK" is almost certainly`)
  249. console.error(` 'terminal'; the numeric ids are Bitrix defaults you will recognise in the UI.`)
  250. }
  251. }
  252. if (!snap.leads.ok) console.error(`\n BITRIX FAILED: ${snap.leads.error}`)
  253. if (!snap.orderLines.ok) console.error(` Plinth FAILED: ${snap.orderLines.error}`)
  254. if (!snap.tasks.ok) console.error(` TASKS FAILED: ${snap.tasks.error}`)
  255. // The client's error already says what happened; the 403 carries its own
  256. // explanation, so nothing is appended here (rule 1).
  257. if (!snap.ninety.ok) console.error(` NINETY FAILED: ${snap.ninety.error}`)
  258. if (historyAllowed && !keepHistory && !renderOnly) {
  259. console.error(`\n Not recorded in the delta history. A run with a dead source would`)
  260. console.error(` become tomorrow's "yesterday" and invent a swing that never happened.`)
  261. }
  262. if (h.unknownStatuses.length) {
  263. console.error(`\n Unclassified product statuses (counted as "our move"):`)
  264. for (const s of h.unknownStatuses) console.error(` ${s}`)
  265. console.error(` Add them to packages/plinth-client/src/status-taxonomy.ts`)
  266. }
  267. console.log(`\nWrote ${decodeURIComponent(out.pathname)}`)
  268. if (renderOnly) {
  269. console.log(`Left ${decodeURIComponent(payloadOut.pathname)} alone. It is the one`)
  270. console.log(`the briefings on this page were written from.`)
  271. } else {
  272. console.log(`Wrote ${decodeURIComponent(payloadOut.pathname)} (schema ${payload.schemaVersion}, ${payload.people.length} people)`)
  273. }
  274. if (!briefings) {
  275. console.log(`\n No written briefing on this page yet. Run \`pnpm morning\` to sync,`)
  276. console.log(` write every briefing, and re-render with them included.`)
  277. } else if (briefings.payloadGeneratedAt !== snap.syncedAt) {
  278. console.log(`\n The written briefing on this page came from an EARLIER sync. Re-run`)
  279. console.log(` \`pnpm morning\` so the prose and the numbers describe the same moment.`)
  280. }
  281. if (!snap.leads.ok || !snap.orderLines.ok) {
  282. console.log('One or more sources failed. The page renders the failure rather than showing zero.')
  283. }
  284. }
  285. main().catch((err) => {
  286. console.error('\nDashboard build failed:', errorMessage(err))
  287. process.exit(1)
  288. })