main.ts
Language TypeScript15.5 KB309 lines
#!/usr/bin/env tsx/*** Build the dashboard.** pnpm dashboard live, using BITRIX_WEBHOOK_URL and PLINTH_API_KEY* pnpm dashboard --fixtures sample data, no credentials needed* pnpm dashboard --days 90 widen the Plinth order window (default 60)* pnpm dashboard --render-only re-render the last sync, no API calls at all** Writes dashboard.html in the repo root and prints a summary. Read-only* throughout: no write method can be reached from here.*/import { localStamp } from '@meridian/ninety-client'import { writeFile } from 'node:fs/promises'import { collect, headline, type Snapshot } from './collect.js'import { classifyOrder } from '@meridian/plinth-client'import { LEAD_STAGES, stageName } from '../../../config/bitrix-stages.js'import { render } from './render.js'import { FIXTURE_SNAPSHOT } from './fixtures.js'import { rowFrom } from './history.js'import { readHistory, recordRun } from './history-file.js'import { buildBriefingPayload } from './briefing-payload.js'import { readBriefings } from './briefings-file.js'import { readSnapshot, writeSnapshot, SNAPSHOT_FILE } from './snapshot-file.js'import { errorMessage } from '@meridian/shared'const args = process.argv.slice(2)const useFixtures = args.includes('--fixtures')/*** Re-render the page from the cached snapshot instead of syncing.** This is the second half of `pnpm morning`. The briefings are written from one* sync's numbers, and the page then has to be rebuilt to show them. Rebuilding* with a fresh sync would move `syncedAt`, so the prose and the numbers would no* longer describe the same moment and every clean run would call its own output* stale. Re-rendering the same sync keeps that warning meaningful (see* snapshot-file.ts).** Needs no credentials, because it makes no requests.*/const renderOnly = args.includes('--render-only')const days = args.includes('--days') ? Number(args[args.indexOf('--days') + 1]) || 60 : 60// Fixture runs must not pollute the real day-over-day history; a demo would// otherwise become "yesterday" for tomorrow's briefing.const historyAllowed = !useFixtures && !args.includes('--no-history')/*** Credentials come from the process environment only.** There is no `.env` reader anywhere in this repo, on purpose. A credentials* file in the working tree is readable by every tool and agent that touches the* repo, and a secret that has been read is a secret that has to be rotated.* Exporting from the shell profile keeps them out of the tree entirely.** The trade is that a new terminal must have sourced the profile, and that is* the failure this message exists to name: when nothing is set, the problem is* almost always the shell, not the repo. Saying "not set" without saying where* to look sends people hunting through code for a loader that does not exist.*/function preflight(): void {const required = ['NINETY_TOKEN', 'BITRIX_WEBHOOK_URL', 'PLINTH_API_KEY'] as constconst missing = required.filter((k) => !process.env[k])if (!missing.length) returnconsole.error(` Missing from the environment: ${missing.join(', ')}`)if (missing.length === required.length) {console.error(` ALL of them are missing, which usually means this shell has not`)console.error(` sourced your profile: a new terminal, or an editor-spawned one.`)} else {console.error(` ${required.length - missing.length} other credential(s) ARE set, so the profile did load.`)console.error(` The ones above are absent from it, or spelled differently there.`)}console.error(`\n Credentials live in the shell profile; this repo has no .env reader.`)console.error(` See what the shell actually has:`)console.error(` env | grep -E 'NINETY|BITRIX|PLINTH_|ANTHROPIC'`)console.error(` Then open a new terminal, or re-source the profile:`)console.error(` source ~/.zshrc # or whichever profile you use\n`)}/** Order lines the team owes a move on, excluding leads. */function orderOurMove(snap: Snapshot): number {return snap.orderLines.data.filter((l) => classifyOrder(l.orderStatus, l.orderStatusId || null) === 'ours',).length}async function main(): Promise<void> {let snap: Snapshotif (renderOnly) {const cached = await readSnapshot()if (!cached.ok) {// Fail rather than fall back to a live sync. A silent fallback is how this// flag would quietly stop doing its job: the page would look right and the// staleness warning would be back, with nothing saying the cache was gone.console.error(`Cannot re-render: ${cached.reason}.`)console.error(` ${decodeURIComponent(SNAPSHOT_FILE.pathname)}`)if (cached.missing) {console.error(`--render-only re-renders the last sync, and there has not been one.`)} else {console.error(`There is a file there but it is not a usable snapshot. Refusing to`)console.error(`render half a page rather than rendering one that looks complete.`)}console.error(`Run \`pnpm dashboard\` first, or \`pnpm morning\` for the whole flow.`)process.exit(1)}snap = cached.snapconsole.log(`Re-rendering the sync from ${localStamp(snap.syncedAt)} CT. No API calls.\n`)} else if (useFixtures) {console.log('Fixture mode: no API calls.\n')snap = FIXTURE_SNAPSHOT} else {preflight()console.log(`Pulling Bitrix leads and Plinth order lines (last ${days} days)...\n`)// The file source is passed explicitly: collect.ts is runtime-neutral so it// can also run in the Worker, which has no filesystem.snap = await collect(process.env, days)}const h = headline(snap)const today = rowFrom(h, new Date(snap.syncedAt))// A run where a source died reports zeros, and a zero recorded today becomes// tomorrow's "yesterday", so the morning after an outage the briefing would// announce that the design queue grew by 37 overnight. It also must not show a// delta of its own: "down 146" is not news, it is the outage. So a broken run// neither writes history nor reads it.const sourcesOk = snap.leads.ok && snap.orderLines.ok && snap.tasks.ok// Read off the snapshot, not off the flag: on a --render-only run the flag is// absent but the cached sync may still be fixture data.const isFixtures = snap.mode === 'fixtures'const keepHistory = historyAllowed && sourcesOk && !renderOnly// Record first, then render against what was on file before this run;// otherwise the page would diff today against itself and every delta is zero.//// A --render-only run reads but never records: the row for today was written by// the sync it is re-rendering, and writing it again would be the second half of// `pnpm morning` overwriting the first. Reading back the full file including// today is safe because `previousRun()` filters to `r.day < today`, so today's// own row cannot become its own comparison, and nothing else on the page// iterates the array.const history = !sourcesOk || isFixtures ? [] : keepHistory ? await recordRun(today) : await readHistory()// Written briefings from the previous brief.py run, if any. Rendering them is// what makes the model output visible on the board instead of only in a// terminal. See briefings-file.ts for why the chain is file-mediated.const briefings = await readBriefings()const html = render(snap, h, history, today.day, briefings)const out = new URL('../../../dashboard.html', import.meta.url)await writeFile(out, html, 'utf8')// Cache the sync so `--render-only` can rebuild this exact page later. Not on// a --render-only run, which would only rewrite what it just read.if (!renderOnly) await writeSnapshot(snap)// The model-facing payload. Written on every run, including fixture runs:// a consumer needs something to develop against without credentials. It is a// separate file from the HTML on purpose: the page is for people, this is for// whatever generates the analysis, and neither should have to parse the other.//// Not rewritten on a --render-only run. `generatedAt` is `snap.syncedAt`, so// rewriting it from the cached snapshot would produce a byte-identical file,// and "render only" should mean what it says. The existing payload is already// the one the briefings were written from.const payload = buildBriefingPayload(snap)const payloadOut = new URL('../../../briefing-payload.json', import.meta.url)if (!renderOnly) {await writeFile(payloadOut, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')}const row = (k: string, v: string | number) => console.log(` ${k.padEnd(22)}${v}`)// Split, because one number covering both is unreadable: leads and order// lines are different kinds of work, with different owners and urgency, and// their sum is a figure nobody can act on.const leadOurs = h.ourMove - orderOurMove(snap)row('our move', `${h.ourMove} (${leadOurs} leads + ${orderOurMove(snap)} order lines)`)row('design queue', h.designQueue)row('client replied', h.needsReply)row('waiting on client', h.waitingOnClient)row('at vendor / transit', h.atVendor)// Three numbers, never one. Open leads can be mostly finished work, and the// excluded counts are shown rather than subtracted, because a number that// quietly shrinks is indistinguishable from one that is wrong.const excluded = [h.completedLeads ? `${h.completedLeads} completed` : null,h.noiseLeads ? `${h.noiseLeads} automated` : null,].filter(Boolean)row('open leads',`${h.realLeads} still open` + (excluded.length ? ` (excluding ${excluded.join(' and ')})` : ''),)// Commitments and assignments, never one alone. A bare row count overstates// the work whenever an item is carried by more than one person.row('commitments',h.commitments === null? 'not tracked (Ninety unavailable)': `${h.commitments} across ${h.assignments} assignment(s)` +(h.sharedCommitments ? `, ${h.sharedCommitments} shared` : ''),)row('to-dos past a review', h.lateTodos === null ? 'not tracked' : h.lateTodos)// Shape diagnostics. A small number is not automatically a wrong number (a// 60-day window is mostly finished orders), but "4 our move" is unreadable// without knowing whether 4 orders came back or 400. Print the denominator.if (snap.orderLines.ok) {const lines = snap.orderLines.dataconst orders = new Set(lines.map((l) => l.orderId)).sizeconsole.log(`\n Plinth: ${orders} orders, ${lines.length} product lines in the last ${days} days`)const byClass = new Map<string, number>()for (const l of lines) {const c = classifyOrder(l.orderStatus, l.orderStatusId || null)byClass.set(c, (byClass.get(c) ?? 0) + 1)}console.log(` ${[...byClass].map(([c, n]) => `${c}=${n}`).join(' ') || '(none)'}`)console.log(` fetched over ${snap.orderWindow.pages} page(s)`)// Compared in lines, because the cap is in lines: an order count against a// line limit could never fire (100 lines is about 60 orders).if (snap.orderWindow.truncated) {console.error(` TRUNCATED at the page cap: these counts are a FLOOR, not a total.`)console.error(` Narrow --days or raise maxPages before trusting any order number.`)}}if (snap.leads.ok) {const w = snap.leadWindowconst real = snap.leads.data.filter((l) => !l.noise)console.log(` Bitrix leads: ${snap.leads.data.length} open` +(w.sinceDays === null ? ' (no date floor)' : `, created in the last ${w.sinceDays} days`) +` over ${w.pages} page(s)`)if (w.truncated) {console.error(` TRUNCATED at the page cap: this count is a FLOOR, not a total.`)console.error(` Narrow the window or raise maxPages before trusting it.`)}// Stage breakdown, because "our move: 212" is unreadable without it. If one// stage holds almost all of them, that stage is the story, and if that// stage is New/Unassigned, the story is automation pollution rather than// work, since that is where email-captured leads land.const byStage = new Map<string, number>()for (const l of real) {const name = stageName(l.statusId) ?? l.statusIdbyStage.set(name, (byStage.get(name) ?? 0) + 1)}const top = [...byStage].sort((a, b) => b[1] - a[1]).slice(0, 8)console.log(` ${real.length} after the noise filter, by stage:`)for (const [name, n] of top) {const stage = LEAD_STAGES.find((s) => s.name === name || s.id === name)const cls = stage ? stage.class : 'UNCLASSIFIED -> counted as ours'console.log(` ${String(n).padStart(4)} ${name} (${cls})`)}if (h.unknownLeadStages.length) {const share = Math.round((h.ourMoveUnclassified / Math.max(h.ourMove, 1)) * 100)console.error(``)console.error(` UNCLASSIFIED LEAD STAGES: ${h.ourMoveUnclassified} of "our move" (${share}%) is unclassified.`)console.error(` config/bitrix-stages.ts was written from a small lead sample. The real`)console.error(` pipeline mostly lives in stages it has never seen, and every unknown`)console.error(` stage defaults to "ours", which is why the headline reads the way it does.`)console.error(` Until these are classified, "our move" is not a workload number.`)for (const u of h.unknownLeadStages.slice(0, 10)) {console.error(` ${String(u.count).padStart(4)} ${u.id}`)}console.error(` Add each to LEAD_STAGES with the right class. "JUNK" is almost certainly`)console.error(` 'terminal'; the numeric ids are Bitrix defaults you will recognise in the UI.`)}}if (!snap.leads.ok) console.error(`\n BITRIX FAILED: ${snap.leads.error}`)if (!snap.orderLines.ok) console.error(` Plinth FAILED: ${snap.orderLines.error}`)if (!snap.tasks.ok) console.error(` TASKS FAILED: ${snap.tasks.error}`)// The client's error already says what happened; the 403 carries its own// explanation, so nothing is appended here (rule 1).if (!snap.ninety.ok) console.error(` NINETY FAILED: ${snap.ninety.error}`)if (historyAllowed && !keepHistory && !renderOnly) {console.error(`\n Not recorded in the delta history. A run with a dead source would`)console.error(` become tomorrow's "yesterday" and invent a swing that never happened.`)}if (h.unknownStatuses.length) {console.error(`\n Unclassified product statuses (counted as "our move"):`)for (const s of h.unknownStatuses) console.error(` ${s}`)console.error(` Add them to packages/plinth-client/src/status-taxonomy.ts`)}console.log(`\nWrote ${decodeURIComponent(out.pathname)}`)if (renderOnly) {console.log(`Left ${decodeURIComponent(payloadOut.pathname)} alone. It is the one`)console.log(`the briefings on this page were written from.`)} else {console.log(`Wrote ${decodeURIComponent(payloadOut.pathname)} (schema ${payload.schemaVersion}, ${payload.people.length} people)`)}if (!briefings) {console.log(`\n No written briefing on this page yet. Run \`pnpm morning\` to sync,`)console.log(` write every briefing, and re-render with them included.`)} else if (briefings.payloadGeneratedAt !== snap.syncedAt) {console.log(`\n The written briefing on this page came from an EARLIER sync. Re-run`)console.log(` \`pnpm morning\` so the prose and the numbers describe the same moment.`)}if (!snap.leads.ok || !snap.orderLines.ok) {console.log('One or more sources failed. The page renders the failure rather than showing zero.')}}main().catch((err) => {console.error('\nDashboard build failed:', errorMessage(err))process.exit(1)})