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

meridian-people.ts

Language TypeScript22.7 KB555 lines

config/meridian-people.ts

  1. /**
  2. * The Meridian roster: the dashboard's primary axis.
  3. *
  4. * The board is a bird's-eye view of per-person workload: who is loaded, who
  5. * is not, at a glance. It is organized around the people below rather than
  6. * around queues; queues are the supporting detail.
  7. *
  8. * No single system covers everyone:
  9. *
  10. * Plinth production owner Marco, Rafael (designers)
  11. * Plinth account owner Sam, Rosa (CS reps)
  12. * Bitrix leads effectively one CS rep only: the assignee field
  13. * is a routing default, so it is not used for workload
  14. * Bitrix tasks whoever is set as RESPONSIBLE_ID
  15. *
  16. * So some people have no Plinth assignment at all. If the page rendered them
  17. * at zero next to a designer at nine, a reader would take that as spare
  18. * capacity and route work to them: the precise failure this board exists to
  19. * prevent, arrived at faster and with more authority because a screen said it.
  20. *
  21. * Partial coverage is normal, not an error. Nobody is covered by every source and plenty of fields come back null. That is
  22. * expected and the page should not treat it as a fault: no red banners, no
  23. * warnings. It is a quiet annotation on the row.
  24. *
  25. * What matters is only this: a person with no covering source must render as
  26. * "not tracked", never as a zero. An empty cell and an untracked one look
  27. * identical to someone scanning quickly, and only one of them means free
  28. * capacity. That single distinction is what keeps this view honest.
  29. */
  30. export type Source =
  31. | 'plinth-designer'
  32. | 'plinth-client-success'
  33. | 'bitrix-tasks'
  34. | 'ninety-todos'
  35. | 'content-tracker'
  36. export interface Person {
  37. /** Display name. Also the key the Plinth account labels map onto. */
  38. name: string
  39. role: string
  40. /** Plinth account labels, exactly as they appear in the export. */
  41. plinthAccounts: string[]
  42. /**
  43. * Ninety user id: the only reliable join. Filled for everyone on the roster;
  44. * keep it that way.
  45. *
  46. * Ninety returns `ownerFirstName` / `ownerLastName` inline on every company
  47. * to-do, spelled as someone typed them there ("Samuel Whitaker", "Marco
  48. * Ibanez" against a roster reading "Sam Whitaker", "Marco Ibáñez"). A
  49. * display-name join silently matches nobody for those people, and their
  50. * briefings show zero to-dos as though they were idle.
  51. *
  52. * Names are typed by humans in another system and will keep drifting. Ids do
  53. * not. Get a new one from `pnpm roster`, or directly:
  54. * curl -H "Authorization: Bearer $NINETY_TOKEN" \
  55. * https://api.public.ninety.io/v1/users
  56. *
  57. * Optional in the type only because a new hire is added here before anyone has
  58. * run `pnpm roster` to get their id. `pnpm company` reports anyone resolving by
  59. * name instead of by id, so an unfilled one does not stay unnoticed.
  60. */
  61. ninetyId?: string
  62. /**
  63. * Spellings seen in other systems. Empty for everyone, deliberately: every
  64. * `ninetyId` is pinned, and aliases would rot into a second, staler source of
  65. * truth.
  66. *
  67. * The mechanism stays, because deleting it would mean a re-created Ninety
  68. * account (new id, same person) silently dropping off the board, which is
  69. * worse than a loose match. But a match made this way is now reported rather
  70. * than absorbed: see `resolveNinety()`.
  71. */
  72. aliases?: string[]
  73. /**
  74. * Google Workspace address, as Cloudflare Access asserts it.
  75. *
  76. * The join that decides what somebody sees. Access authenticates against
  77. * Workspace and passes the address in a signed JWT; the Worker maps it back to
  78. * this roster to pick which briefing to serve. It must be the Workspace
  79. * address, which is not derivable from the display name: several people sign
  80. * in with a role mailbox such as `hello@meridiandisplay.example`.
  81. *
  82. * An address here that nobody signs in with is harmless. An address that
  83. * belongs to somebody else would show them another person's briefing, so this
  84. * field is never guessed.
  85. *
  86. * Absent means that person cannot be identified and falls back to the
  87. * company-only view, same as anyone not on the roster.
  88. */
  89. email?: string
  90. /**
  91. * May read the Leadership Team to-dos.
  92. *
  93. * Granted per person, by name, and never inherited with a row (see Rosa's).
  94. * The designers are deliberately not on it. This is a viewing right and does
  95. * not track who holds Leadership work: a person can hold none and still see
  96. * it.
  97. */
  98. seesLeadership?: boolean
  99. /**
  100. * May read the executive briefing. Exactly one person, checked at load.
  101. *
  102. * A hard requirement (the executive briefing has one reader), so not a soft
  103. * toggle. The exec brief is omitted from every other viewer's page at render
  104. * time rather than hidden in the markup, because hidden markup is not hidden:
  105. * anyone can open view-source.
  106. */
  107. seesExec?: boolean
  108. /**
  109. * Bitrix user id, for tasks. Read from `user.get` and pinned for everyone on
  110. * the roster; see `personFromBitrix()` for why this is an id match and not a
  111. * name one.
  112. *
  113. * Optional in the type only so a new hire can be added here before anyone runs
  114. * the lookup. An unfilled one is reported rather than absorbed.
  115. */
  116. bitrixUserId?: number
  117. /**
  118. * Which sources can say anything about this person's load.
  119. * Empty means the dashboard genuinely cannot see their work.
  120. */
  121. coveredBy: Source[]
  122. /** Shown on the page when coverage is partial or absent. */
  123. coverageNote?: string
  124. }
  125. export const MERIDIAN_PEOPLE: Person[] = [
  126. {
  127. name: 'Sam Whitaker',
  128. role: 'Client Success',
  129. plinthAccounts: ['Sam-Accounts-Desk'],
  130. ninetyId: '3e894807d73d5a39b03fb411',
  131. bitrixUserId: 1924,
  132. email: 'hello@meridiandisplay.example',
  133. seesLeadership: true,
  134. coveredBy: ['plinth-client-success', 'bitrix-tasks', 'ninety-todos', 'content-tracker'],
  135. },
  136. {
  137. /**
  138. * Rosa holds the Client Success role and mailbox that Hazel held, so she
  139. * took over that row rather than being added beside it. Only the fields
  140. * that describe the role transfer:
  141. *
  142. * plinthAccounts transfers: Plinth still holds the literal string
  143. * "Hazel (Desk 2)" and that value now means Rosa, so
  144. * orders assigned before the change show as hers. That
  145. * is the stated meaning of the reused label.
  146. *
  147. * ninetyId does not: an id is a person's account, and reusing
  148. * bitrixUserId one would attribute somebody else's work to Rosa.
  149. * Both below are her own. The Bitrix id cannot be
  150. * verified from this repo, which holds no credential;
  151. * a credentialled `pnpm dashboard` confirms it by
  152. * attributing her tasks, or Data health names her as
  153. * uncovered.
  154. *
  155. * seesLeadership is absent by decision, not by default: reading the
  156. * Leadership Team's to-dos is set per person by name and was not granted.
  157. * The inbox is granted separately in config/inbox.ts. Getting less than
  158. * you should is a bug report; getting more is an incident.
  159. */
  160. name: 'Rosa Galván',
  161. role: 'Client Success',
  162. plinthAccounts: ['Hazel (Desk 2)'],
  163. ninetyId: '40a05e495e934c8a2eb4c690',
  164. bitrixUserId: 11776,
  165. email: 'accounts@meridiandisplay.example',
  166. // Each source is listed only with its id. An id without the source it
  167. // unlocks, or the source without the id, is a known shape of bug:
  168. // collected, attributed, and then dropped before it reaches the page.
  169. // content-tracker is absent because she owns no tracker rows.
  170. coveredBy: ['plinth-client-success', 'ninety-todos', 'bitrix-tasks'],
  171. coverageNote:
  172. 'New to the role. Her Plinth orders arrive through the unchanged "Hazel (Desk 2)" ' +
  173. 'assignment, so that count includes orders assigned before she got here. That is the ' +
  174. 'inherited queue, not a day-one workload. Her Ninety and Bitrix ids are her own accounts, so ' +
  175. 'those two count only her own work.',
  176. },
  177. {
  178. name: 'Marco Ibáñez',
  179. role: 'Designer',
  180. plinthAccounts: ['Marco-Acct7-Desk'],
  181. ninetyId: '8466ef547c9f9a6ee92fe8a0',
  182. bitrixUserId: 7108,
  183. email: 'studio@meridiandisplay.example',
  184. coveredBy: ['plinth-designer', 'bitrix-tasks', 'ninety-todos', 'content-tracker'],
  185. },
  186. {
  187. name: 'Rafael Montero',
  188. role: 'Designer',
  189. plinthAccounts: ['Rafael-Prod-Desk'],
  190. ninetyId: '05b7014d82e5b5ed5e68b309',
  191. bitrixUserId: 9256,
  192. email: 'orderdesk@meridiandisplay.example',
  193. coveredBy: ['plinth-designer', 'bitrix-tasks', 'ninety-todos', 'content-tracker'],
  194. },
  195. {
  196. name: 'Ines Carvalho',
  197. role: 'Marketing',
  198. plinthAccounts: [],
  199. ninetyId: '4e77bc9877e856992de26b64',
  200. bitrixUserId: 7404,
  201. email: 'projects@meridiandisplay.example',
  202. seesLeadership: true,
  203. coveredBy: ['bitrix-tasks', 'ninety-todos', 'content-tracker'],
  204. coverageNote:
  205. 'No Plinth assignment. Ines is never a production owner or account owner, so her ' +
  206. 'order work is invisible here. Only Bitrix tasks are counted, which is a partial view.',
  207. },
  208. {
  209. name: 'Theo Bassett',
  210. role: 'Operations',
  211. plinthAccounts: [],
  212. ninetyId: '6d8c1631dc0c3eda4c26b839',
  213. bitrixUserId: 1698,
  214. email: 'frontdesk@meridiandisplay.example',
  215. seesLeadership: true,
  216. coveredBy: ['bitrix-tasks', 'ninety-todos', 'content-tracker'],
  217. coverageNote:
  218. 'No Plinth assignment. Only Bitrix tasks are counted, which is a partial view.',
  219. },
  220. {
  221. name: 'Nora Lindqvist',
  222. role: 'Owner / General Manager',
  223. plinthAccounts: [],
  224. ninetyId: '8326a8602530a97b5afd91ae',
  225. bitrixUserId: 1,
  226. email: 'nora.l@meridiandisplay.example',
  227. seesLeadership: true,
  228. seesExec: true,
  229. coveredBy: ['bitrix-tasks', 'ninety-todos', 'content-tracker'],
  230. coverageNote:
  231. 'No Plinth assignment. Ninety commitments and Bitrix tasks only, which is a partial view.',
  232. },
  233. ]
  234. /**
  235. * People in the Ninety directory who are deliberately not on this roster.
  236. *
  237. * Ninety's user list is not the Meridian team: it also holds former staff and
  238. * outside collaborators. Without this, every sweep reports them as "unmapped"
  239. * and someone eventually chases a coverage gap that does not exist.
  240. *
  241. * Listing them is not the same as hiding them. An unmapped owner is a question
  242. * to answer once; an entry here is that answer, written down.
  243. */
  244. export const KNOWN_NON_ROSTER_NINETY = [
  245. 'Grant Holloway', // former business coach, no longer with Meridian
  246. /**
  247. * Former client success; Rosa Galván took over the role and the mailbox.
  248. * Listed here so the sweep stops reporting her as an unmapped owner: the
  249. * answer to that question, written down.
  250. *
  251. * This does not say that her open to-dos went anywhere. Anything still
  252. * assigned to her Ninety account is now held by nobody on this roster and
  253. * appears in no per-person view. If that matters, the fix is in Ninety:
  254. * reassign them, not here.
  255. */
  256. 'Hazel Okafor',
  257. ]
  258. /**
  259. * The board's primary audience is also a row.
  260. *
  261. * The person managing everyone's bandwidth can hold more open commitments than
  262. * anyone, and leaving them out would resolve that work to nobody: on no
  263. * per-person view, while still counting in every company total. A capacity
  264. * view that omits its largest single holder is not a capacity view.
  265. * And her own leadership brief should include her own load, or it is asking her
  266. * to reason about everyone's bandwidth except her own.
  267. */
  268. export const AUDIENCE = 'Nora'
  269. const byAccount = new Map<string, Person>()
  270. for (const p of MERIDIAN_PEOPLE) for (const a of p.plinthAccounts) byAccount.set(a.toLowerCase(), p)
  271. const byName = new Map(MERIDIAN_PEOPLE.map((p) => [p.name.toLowerCase(), p]))
  272. /** Canonical name and every known alias, lowercased. */
  273. const byAnyName = new Map<string, Person>()
  274. for (const p of MERIDIAN_PEOPLE) {
  275. byAnyName.set(p.name.toLowerCase(), p)
  276. for (const a of p.aliases ?? []) byAnyName.set(a.toLowerCase(), p)
  277. }
  278. const byEmail = new Map<string, Person>()
  279. for (const p of MERIDIAN_PEOPLE) if (p.email) byEmail.set(p.email.trim().toLowerCase(), p)
  280. /**
  281. * Google Workspace address -> roster person. Null rather than a guess.
  282. *
  283. * Null is the safe return and callers must treat it that way: an address this
  284. * roster does not know gets the company-only view, never someone else's. There
  285. * is deliberately no fuzzy match, no domain-prefix rule, no fallback to display
  286. * name. Most of the addresses are role mailboxes (hello@, orderdesk@, accounts@,
  287. * frontdesk@, projects@), so any rule inferred from names would be wrong for
  288. * almost everyone, and wrong here means showing one person another's briefing.
  289. */
  290. export function personFromEmail(email: string | null | undefined): Person | null {
  291. const key = (email ?? '').trim().toLowerCase()
  292. return key ? (byEmail.get(key) ?? null) : null
  293. }
  294. /**
  295. * Load-time invariants. These are access-control facts, so a typo must fail
  296. * the build rather than quietly widening who can read what. A duplicated
  297. * address would hand two people the same identity; a second `seesExec` would
  298. * break the exec brief's single-reader rule.
  299. */
  300. const emailCount = MERIDIAN_PEOPLE.filter((p) => p.email).length
  301. if (byEmail.size !== emailCount) {
  302. throw new Error(
  303. `config/meridian-people.ts: ${emailCount} people carry an email but only ${byEmail.size} are ` +
  304. `distinct. Two people share an address, which would give them the same identity.`,
  305. )
  306. }
  307. const execHolders = MERIDIAN_PEOPLE.filter((p) => p.seesExec).map((p) => p.name)
  308. if (execHolders.length !== 1) {
  309. throw new Error(
  310. `config/meridian-people.ts: exactly one person may have seesExec; found ${execHolders.length}` +
  311. (execHolders.length ? ` (${execHolders.join(', ')})` : '') +
  312. `. The executive briefing is for one reader by decision.`,
  313. )
  314. }
  315. for (const p of MERIDIAN_PEOPLE) {
  316. if (p.seesExec && !p.seesLeadership) {
  317. throw new Error(
  318. `config/meridian-people.ts: ${p.name} sees the exec briefing but not Leadership to-dos. ` +
  319. `The exec view summarises them, so that combination leaks by summary.`,
  320. )
  321. }
  322. }
  323. const byNinetyId = new Map<string, Person>()
  324. for (const p of MERIDIAN_PEOPLE) if (p.ninetyId) byNinetyId.set(p.ninetyId, p)
  325. /**
  326. * How a Ninety owner was matched to a roster person.
  327. *
  328. * `via: 'name'` is news, not a detail. Every person carries a `ninetyId`, so a
  329. * name match means the id Ninety sent is one this roster has never seen: a
  330. * re-created account, or an id that changed underneath us. The
  331. * work still attributes correctly, which is exactly why it would otherwise go
  332. * unnoticed until the name drifted too and the person quietly emptied out.
  333. *
  334. * A fallback standing in for knowledge has to be surfaced every run, not
  335. * asserted and forgotten (rule 2).
  336. */
  337. export interface NinetyMatch {
  338. person: Person | null
  339. via: 'id' | 'name' | null
  340. }
  341. export function resolveNinety(
  342. ownerId: string | null | undefined,
  343. ownerName: string | null | undefined,
  344. ): NinetyMatch {
  345. if (ownerId) {
  346. const byId = byNinetyId.get(ownerId)
  347. if (byId) return { person: byId, via: 'id' }
  348. }
  349. if (ownerName?.trim()) {
  350. const byNameMatch = byAnyName.get(ownerName.trim().toLowerCase())
  351. if (byNameMatch) return { person: byNameMatch, via: 'name' }
  352. }
  353. return { person: null, via: null }
  354. }
  355. /**
  356. * Ninety user -> roster person. Id first, name second, and null rather than a
  357. * guess.
  358. *
  359. * Null is the important return. Absorbed into an empty list, an unmatched
  360. * owner renders as having no work, indistinguishable from being idle. Callers
  361. * must surface a null as "unmapped". See `unmappedNinetyOwners()`.
  362. *
  363. * Use `resolveNinety()` instead when you can report how the match was made.
  364. */
  365. export function personFromNinety(
  366. ownerId: string | null | undefined,
  367. ownerName: string | null | undefined,
  368. ): Person | null {
  369. return resolveNinety(ownerId, ownerName).person
  370. }
  371. /**
  372. * Ninety owners that match nobody on the roster. Non-empty is a problem to fix,
  373. * not a curiosity: every to-do behind one of these names is missing from the
  374. * board, and missing looks exactly like finished.
  375. */
  376. export function unmappedNinetyOwners(
  377. owners: { id: string; name: string }[],
  378. ): { id: string; name: string }[] {
  379. const known = new Set(KNOWN_NON_ROSTER_NINETY.map((n) => n.toLowerCase()))
  380. const seen = new Map<string, { id: string; name: string }>()
  381. for (const o of owners) {
  382. if (known.has(o.name.trim().toLowerCase())) continue
  383. if (!personFromNinety(o.id, o.name)) seen.set(o.id || o.name, o)
  384. }
  385. return [...seen.values()]
  386. }
  387. /** Plinth account label -> roster person. */
  388. export function personFromPlinthAccount(label: string | null | undefined): Person | null {
  389. if (!label?.trim()) return null
  390. return byAccount.get(label.trim().toLowerCase()) ?? null
  391. }
  392. export function personByName(name: string | null | undefined): Person | null {
  393. if (!name?.trim()) return null
  394. return byName.get(name.trim().toLowerCase()) ?? null
  395. }
  396. export const SOURCE_LABELS: Record<Source, string> = {
  397. 'content-tracker': 'Content Creation tracker',
  398. 'plinth-designer': 'Plinth design assignments',
  399. 'plinth-client-success': 'Plinth client-success assignments',
  400. 'bitrix-tasks': 'Bitrix tasks',
  401. 'ninety-todos': 'Ninety to-dos',
  402. }
  403. const byBitrixId = new Map<string, Person>()
  404. for (const p of MERIDIAN_PEOPLE) if (p.bitrixUserId) byBitrixId.set(String(p.bitrixUserId), p)
  405. /**
  406. * Bitrix `responsibleId` -> roster person. Id only. No name fallback, on purpose.
  407. *
  408. * `Task.responsibleId` is Bitrix's numeric user id, stringified, so it must be
  409. * matched against `bitrixUserId`. Passing it to `personByName()` looks up a
  410. * display name and never matches, which renders every person at "+ 0 tasks".
  411. *
  412. * No name fallback, unlike `resolveNinety()`. Bitrix does not send a name on
  413. * a task at all (only the id), so there is nothing to fall back to. And the
  414. * directory holds 64 accounts against a roster of 7, including "Meeting Room",
  415. * "Front Desk" and "Backup-Paige Lorenz". Fuzzy matching into that is a way to
  416. * attribute someone else's work to a colleague.
  417. *
  418. * Null is the common case here, and it is not an error. Most of the accounts
  419. * are deliberately out of scope, so a null means "not one of the
  420. * seven", not "attribution failed". Callers count them via
  421. * `offRosterBitrixIds()` so they are stated rather than silently absorbed, and
  422. * must not raise them as coverage gaps.
  423. */
  424. export function personFromBitrix(responsibleId: string | null | undefined): Person | null {
  425. if (!responsibleId?.trim()) return null
  426. return byBitrixId.get(responsibleId.trim()) ?? null
  427. }
  428. /**
  429. * Task-holding Bitrix ids that are nobody on the roster, with their task counts.
  430. *
  431. * Not the same thing as `unmappedNinetyOwners()`, and the difference is the
  432. * whole point. An unmapped Ninety owner is a bug: Ninety is company-scoped and
  433. * every owner in it should be on the roster. An off-roster Bitrix id is expected:
  434. * the directory is the whole company's history, 40 of 64 accounts are already
  435. * marked inactive by Bitrix, and the dashboard covers seven people.
  436. *
  437. * So this exists to be reported quietly on Data health, not to be chased. What it
  438. * guards against is the other failure: a roster person's id going stale and their
  439. * tasks quietly joining this bucket, which would look exactly like them having
  440. * nothing to do.
  441. */
  442. export function offRosterBitrixIds(
  443. responsibleIds: (string | null | undefined)[],
  444. ): { id: string; tasks: number }[] {
  445. const counts = new Map<string, number>()
  446. for (const id of responsibleIds) {
  447. const key = id?.trim()
  448. if (!key || personFromBitrix(key)) continue
  449. counts.set(key, (counts.get(key) ?? 0) + 1)
  450. }
  451. return [...counts.entries()]
  452. .map(([id, tasks]) => ({ id, tasks }))
  453. .sort((a, b) => b.tasks - a.tasks || Number(a.id) - Number(b.id))
  454. }
  455. /**
  456. * The Content Creation tracker spells owners as first names: "Rafael",
  457. * "Marco", "Nora & Sam". It is a spreadsheet a human types into, so there is no
  458. * id to match on and there never will be.
  459. *
  460. * First-name matching is only safe because it is checked. The load-time
  461. * assertion below refuses to start if two roster people ever share a first name,
  462. * which is the exact day this join would begin attributing one person's work to
  463. * another and never say so. A second "Sam" or "Marco" is a hiring decision, not a
  464. * code change, so the failure has to come from the roster rather than from
  465. * review.
  466. *
  467. * Scoped deliberately: this is not a general `personByFirstName()`. Every other
  468. * source on this board carries an id, and none of them should be tempted into
  469. * matching this way.
  470. */
  471. const byFirstName = new Map<string, Person>()
  472. for (const p of MERIDIAN_PEOPLE) {
  473. const first = p.name.trim().split(/\s+/)[0]!.toLowerCase()
  474. const clash = byFirstName.get(first)
  475. if (clash) {
  476. throw new Error(
  477. `config/meridian-people.ts: ${clash.name} and ${p.name} share the first name ` +
  478. `"${first}". The Content Creation tracker names owners by first name only, ` +
  479. `so this would silently attribute one person's work to the other. Give the ` +
  480. `tracker a way to tell them apart before adding the second one.`,
  481. )
  482. }
  483. byFirstName.set(first, p)
  484. }
  485. /**
  486. * Tracker Owner cell -> roster person. Null for anyone not on the roster.
  487. *
  488. * Null must be surfaced, never absorbed: a row behind an unmapped owner is
  489. * missing from every per-person view, and missing looks exactly like finished.
  490. * See `unmappedTrackerOwners()`.
  491. */
  492. export function personFromTrackerOwner(owner: string | null | undefined): Person | null {
  493. const key = (owner ?? '').trim().toLowerCase()
  494. if (!key) return null
  495. return byFirstName.get(key) ?? byName.get(key) ?? null
  496. }
  497. /** Tracker owner spellings that match nobody on the roster. */
  498. export function unmappedTrackerOwners(owners: string[]): string[] {
  499. const seen = new Set<string>()
  500. for (const o of owners) {
  501. const key = o.trim()
  502. if (key && !personFromTrackerOwner(key)) seen.add(key)
  503. }
  504. return [...seen].sort()
  505. }
  506. /** Roster people with no Bitrix id pinned. Non-empty means tasks go unattributed. */
  507. export function unpinnedBitrixPeople(): string[] {
  508. return MERIDIAN_PEOPLE.filter((p) => p.coveredBy.includes('bitrix-tasks') && !p.bitrixUserId).map(
  509. (p) => p.name,
  510. )
  511. }
  512. /**
  513. * Roster people Bitrix cannot see at all: no id, and no claim to be covered.
  514. *
  515. * The sibling of `unpinnedBitrixPeople`, and a different fact. That one reports
  516. * a person the board says Bitrix covers who has no id: a bug, because their
  517. * tasks silently fail to attribute. This one reports a person nothing claims to
  518. * cover (a new hire, typically): not a bug, but not nothing either. Their task
  519. * count is blank, and this is how the page says why.
  520. *
  521. * Takes its list as an argument so both states can be tested against a
  522. * synthetic roster rather than against whoever is mid-onboarding today.
  523. */
  524. export function uncoveredByBitrix(people: Person[] = MERIDIAN_PEOPLE): string[] {
  525. return people
  526. .filter((p) => !p.bitrixUserId && !p.coveredBy.includes('bitrix-tasks'))
  527. .map((p) => p.name)
  528. }