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

company.ts

Language TypeScript8.0 KB172 lines

scripts/company.ts

  1. #!/usr/bin/env tsx
  2. /**
  3. * Company sweep. Read-only.
  4. *
  5. * The whole company's open to-dos from one admin token, via GET /todos/company.
  6. * This is the query the dashboard and the morning briefing run on.
  7. *
  8. * pnpm company # hydrated (default): full 33-field records
  9. * pnpm company -- --thin # index only, one request, no hydration
  10. *
  11. * A 403 here is a role problem, not a code problem: the endpoint is restricted
  12. * to company Owners and Admins. The error message says so.
  13. *
  14. * What to look at in the output:
  15. *
  16. * - assignments vs commitments. These are different numbers and the gap is
  17. * shared work. Never put "40 open to-dos" on a page without the other one.
  18. * - roster.seed. If it is 0 and owners < directory size, somebody with no open
  19. * work is invisible on the dashboard. Fill roster.json.
  20. * - sharedLinks.linkable. If this ever prints true, Ninety has started joining
  21. * shared copies and assignmentGroups() can stop guessing from titles.
  22. */
  23. import { clientFromEnv, assignmentGroups } from '@meridian/ninety-client'
  24. import { resolveNinety, unmappedNinetyOwners } from '../config/meridian-people.js'
  25. import { loadRoster } from './lib.js'
  26. import { errorMessage } from '@meridian/shared'
  27. const tally = (fs: { message: string; status: number | null }[]) => {
  28. const m = new Map<string, number>()
  29. for (const f of fs) {
  30. const k = `${f.status ? f.status + ' ' : ''}${f.message}`.slice(0, 74)
  31. m.set(k, (m.get(k) ?? 0) + 1)
  32. }
  33. return m
  34. }
  35. const owners = (fs: { owner: string }[]) => [...new Set(fs.map((f) => f.owner))].join(', ')
  36. async function main(): Promise<void> {
  37. const thin = process.argv.includes('--thin')
  38. const seed = await loadRoster()
  39. const client = clientFromEnv(process.env, seed)
  40. console.log(seed?.length ? `Pinned roster: ${seed.length} from roster.json` : 'No roster.json, harvesting only')
  41. const started = Date.now()
  42. const snap = await client.companySnapshot({ detailed: !thin })
  43. const elapsed = ((Date.now() - started) / 1000).toFixed(1)
  44. console.log(`Company sweep (${thin ? 'index only' : 'hydrated'}, ${elapsed}s)`)
  45. console.log(`synced at ${snap.syncedAt}`)
  46. console.log()
  47. const { assignments, commitments, sharedCommitments, maxOwners } = snap.spread
  48. console.log(`assignments ${assignments} <- rows returned`)
  49. console.log(`commitments ${commitments} <- distinct pieces of work`)
  50. console.log(`shared ${sharedCommitments} (max ${maxOwners} owners on one item)`)
  51. console.log(`recurrence dupes ${snap.recurrence.duplicates}`)
  52. const degraded = snap.hydrationFailures.filter((f) => f.degraded)
  53. const failed = snap.hydrationFailures.filter((f) => !f.degraded)
  54. if (failed.length) {
  55. console.log(`hydration FAILED ${failed.length} of ${snap.todos.length}, kept the thin index row`)
  56. for (const [k, n] of tally(failed)) console.log(` x${n} ${k}`)
  57. console.log(` owners: ${owners(failed)}`)
  58. if (failed.some((f) => f.status === 403)) {
  59. console.log(` 403: GET /todos/{id} does NOT inherit the company`)
  60. console.log(` endpoint's elevation.`)
  61. }
  62. if (failed.some((f) => f.status === 429)) {
  63. console.log(` 429: back off, lower NINETY_RPS.`)
  64. }
  65. }
  66. if (degraded.length) {
  67. // Not a failure. Every field the board reads is present; something the board
  68. // does not read was dropped. Worth seeing, not worth alarm.
  69. console.log(`hydration degraded ${degraded.length} of ${snap.todos.length}: hydrated, some unread fields dropped`)
  70. for (const [k, n] of tally(degraded)) console.log(` x${n} ${k}`)
  71. console.log(` owners: ${owners(degraded)}`)
  72. }
  73. if (!failed.length && !degraded.length) {
  74. console.log(`hydration all ${snap.todos.length} rows full-fidelity`)
  75. }
  76. console.log()
  77. // Per person. Every row counts here; deduping would erase co-owners.
  78. //
  79. // Names are shown roster-resolved, because Ninety's spelling and the roster's
  80. // routinely differ ("Samuel Whitaker" vs "Sam Whitaker") and a raw sweep that
  81. // prints Ninety's version cannot tell you whether the join works. This is the
  82. // cheapest place to catch an attribution break, so it checks here.
  83. const byOwner = new Map<string, { n: number; ninety: string; mapped: boolean; via: string | null }>()
  84. for (const t of snap.todos) {
  85. const { person, via } = resolveNinety(t.owner.id, t.owner.name)
  86. const key = person?.name ?? t.owner.name
  87. const prev = byOwner.get(key)
  88. byOwner.set(key, {
  89. n: (prev?.n ?? 0) + 1,
  90. ninety: t.owner.name,
  91. mapped: Boolean(person),
  92. via,
  93. })
  94. }
  95. console.log('OPEN BY OWNER'.padEnd(30) + 'COUNT')
  96. console.log('-'.repeat(52))
  97. for (const [name, o] of [...byOwner].sort((a, b) => b[1].n - a[1].n)) {
  98. const drift = o.mapped && o.ninety !== name ? ` (Ninety: ${o.ninety})` : ''
  99. const flag = o.mapped ? '' : ' <-- UNMAPPED, on no per-person view'
  100. console.log(name.padEnd(30) + String(o.n).padStart(5) + drift + flag)
  101. }
  102. console.log()
  103. // Every person has a ninetyId, so a name match means the id Ninety sent is
  104. // one this roster has never seen. The work still attributes correctly, which
  105. // is exactly why it would otherwise pass unnoticed until the name drifted too
  106. // and that person quietly emptied out. Surfaced rather than absorbed.
  107. const byNameOnly = [...byOwner].filter(([, o]) => o.via === 'name')
  108. if (byNameOnly.length) {
  109. console.log(`MATCHED BY NAME, NOT BY ID ${byNameOnly.length} owner(s).`)
  110. console.log(` Their to-dos are attributed correctly today. They will stop`)
  111. console.log(` being attributed at all the moment the spelling changes.`)
  112. for (const [name, o] of byNameOnly) {
  113. console.log(` - ${name} (Ninety sent "${o.ninety}")`)
  114. }
  115. console.log(` Fix: run \`pnpm roster\` and update ninetyId in`)
  116. console.log(` config/meridian-people.ts. Do not add an alias instead.`)
  117. console.log()
  118. }
  119. const unmapped = unmappedNinetyOwners(
  120. snap.todos.map((t) => ({ id: t.owner.id, name: t.owner.name })),
  121. )
  122. if (unmapped.length) {
  123. const lost = snap.todos.filter((t) => !personFromNinety(t.owner.id, t.owner.name)).length
  124. console.log(`ATTRIBUTION GAP ${lost} of ${snap.todos.length} to-dos belong to nobody on the roster.`)
  125. console.log(` They count in company totals and appear on NO per-person view,`)
  126. console.log(` which reads as those people having nothing.`)
  127. for (const u of unmapped) console.log(` - ${u.name} (${u.id})`)
  128. console.log(` Fix: add to MERIDIAN_PEOPLE with their ninetyId from \`pnpm roster\`,`)
  129. console.log(` or declare them in KNOWN_NON_ROSTER_NINETY if they are not staff.`)
  130. console.log()
  131. }
  132. const shared = assignmentGroups(snap.todos).filter((g) => g.shared)
  133. if (shared.length) {
  134. console.log('SHARED COMMITMENTS (grouped by title: heuristic, see assignmentGroups())')
  135. console.log('-'.repeat(78))
  136. for (const g of shared.sort((a, b) => b.owners.length - a.owners.length)) {
  137. console.log(` x${g.owners.length} ${g.title.slice(0, 56)}`)
  138. console.log(` ${g.owners.map((o) => o.name).join(', ')}`)
  139. }
  140. console.log()
  141. }
  142. console.log(`roster seed=${snap.roster.seed} noted=${snap.roster.noted} fetch=${snap.roster.fetch} unresolved=${snap.roster.unresolved}`)
  143. if (snap.unresolvedUsers.length) {
  144. console.log(` unresolved ids: ${snap.unresolvedUsers.join(', ')}`)
  145. }
  146. if (snap.sharedLinks) {
  147. const a = snap.sharedLinks
  148. console.log()
  149. console.log(`sharedLinks groups=${a.sharedGroups} seriesId=${a.sharesSeriesId} milestoneId=${a.sharesMilestoneId} linkable=${a.linkable}`)
  150. if (a.linkable) {
  151. console.log(' -> Ninety now links shared copies. Replace the title heuristic in')
  152. console.log(' assignmentGroups() with that field and delete sharedLinkAudit().')
  153. }
  154. }
  155. }
  156. main().catch((err) => {
  157. console.error(`\nCompany sweep failed: ${errorMessage(err)}`)
  158. process.exit(1)
  159. })