collect.ts
Language TypeScript37.4 KB936 lines
/*** Pull both sources and reduce to what the page renders.** Design rules (see docs/ARCHITECTURE.md):** - Group by stage, never by assignee. Bitrix's assignee is a routing default;* every open lead sampled belonged to one person.* - Count product lines, not orders. Order status and product status disagree.* - Only 'ours' counts as load. Waiting-on-client is a follow-up list.* - Flag automation noise; never silently drop it.* - Report per-source failure. A source that dies must show as broken, not as* zero. A dashboard that quietly reports zero is worse than one that errors.*/import {BitrixClient,LeadsResource,TasksResource,partitionTasks,type Lead,type Task,} from '@meridian/bitrix-client'import { fetchTracker, type TrackerTask } from '@meridian/sheets-client'import {TRACKER_SHEET_ID,TRACKER_TABS,classifyTrackerStatus,type TrackerClass,} from '../../../config/content-tracker.js'import { MERIDIAN_PEOPLE, personByName, personFromBitrix, personFromNinety, personFromTrackerOwner, unmappedTrackerOwners, offRosterBitrixIds, unpinnedBitrixPeople, SOURCE_LABELS, type Person } from '../../../config/meridian-people.js'import {attributeFathomRef,isUnownedRef,participantsOf,COMMITMENT_WINDOW_DAYS,} from '../../../config/fathom-people.js'import { FATHOM_KEYS, FATHOM_WINDOW_DAYS } from '../../../config/fathom-keys.js'import { FathomClient, MeetingsResource } from '@meridian/fathom-client'import {PlinthClient,OrdersResource,displayOrderNumber,isKnownOrderStatus,classifyProduct,classifyLine,isLineDesignQueue,isOrderPastFulfilment,type AssignmentFieldCoverage,type OrderLine,type OrderWindow,type WorkClass,} from '@meridian/plinth-client'import {assignmentSpread,clientFromEnv,isActuallyLate,reviewGatesMissed,type Todo,} from '@meridian/ninety-client'import { LEAD_STAGES, classify as classifyLead, stageName, unknownStageIds } from '../../../config/bitrix-stages.js'import { displayName, isUnmappedAccount } from '../../../config/plinth-people.js'import { errorMessage } from '@meridian/shared'export interface SourceResult<T> {ok: booleandata: Terror: string | null}export interface Snapshot {syncedAt: string/*** Where this snapshot came from. Every status, banner and payload field reads* this first, so a fixture run can never render "live" pills over a frozen* export: a source asserting a status it has not earned is the failure this* board is built to avoid.*/mode: 'live' | 'fixtures'/** When the frozen data was captured. Null for a live run. */fixtureCapturedAt: string | nullleads: SourceResult<Lead[]>orderLines: SourceResult<OrderLine[]>tasks: SourceResult<Task[]>/*** Company-wide Ninety to-dos, from one admin token via GET /todos/company.** A 403 here is about the token's Ninety role, not a bug. The error text says* so.*/ninety: SourceResult<Todo[]>/*** The Content Creation tracker (Google Sheet).** It is the only source here that is a hand-maintained document rather than a* system of record, which makes it the one most likely to go quietly stale,* and the one we can say the least about, because the public CSV endpoint* carries no last-modified. Both facts are on the page rather than in this* comment alone. See config/content-tracker.ts.*/sheet: SourceResult<TrackerTask[]>/** Which tabs answered and which did not. A partial read is reported, never thrown. */sheetTabs: { ok: { label: string; gid: string; rows: number }[]; failed: { label: string; gid: string; reason: string }[] }/*** Assignments vs commitments. Never render one without the other: a real* share of rows are one commitment carried by two or three people, so a bare* row count overstates the work and hides that it is jointly owned.*/ninetySpread: ReturnType<typeof assignmentSpread> | null/*** How the lead list was bounded. `sinceDays` changes what "open leads" means,* so it belongs next to the number rather than buried in a config file, and* `truncated` means the count is a floor rather than a total.*/leadWindow: LeadWindow/*** How the order sweep was bounded. Same reason `leadWindow` exists: Plinth pages* by product line and reports no total, so `truncated` is the only way to know* whether these counts are the window or a slice of it.*/orderWindow: OrderWindowassignmentWarnings: string[]/*** Meetings and their action items, from the Fathom keys in config.** Every meeting the keys can see is in here, including ones most viewers must* never be shown. Nothing in this array is safe to render as-is: the gate is* `meetingsFor(snap, viewer)` in view-model.ts, and the company variant gets* none of it. That is a deliberate trade: one fetch, one snapshot, filtered* per viewer at render time, the same shape as every other source here.*/meetings: SourceResult<MeetingRecord[]>/*** False when no Fathom key is configured. Then `meetings` is empty because* nobody was asked, which must read as "not tracked" and never as "no meetings".* An empty meeting list and an unconfigured source look identical otherwise,* and one of them means free capacity.*/fathomConfigured: boolean/*** One row per key that was actually asked, in the order they were asked.** A list and not a boolean because `meetings` is one SourceResult covering* several fetches, so on its own it cannot distinguish "every key read* cleanly" from "one key is revoked". The second is a partial view of the* company's meetings, and the difference is invisible in the merged list: the* key owner's page would simply be short, and short is what a quiet week* looks like. Named per owner so the page can say which key is dark (rule 1).** Empty when no key is configured at all. Then `fathomConfigured` is false* and the whole source reads as not tracked.*/fathomSources: FathomSourceStatus[]}/*** How one Fathom key's own sweep went. `returned` counts what that key sent,* before the cross-key dedupe, so the numbers here sum to at least the length* of the merged list and never to less.*/export interface FathomSourceStatus {/** Roster display name of the key's owner, from config/fathom-keys.ts. */owner: stringok: boolean/** The failure as reported, verbatim. Never a diagnosis. */error: string | nullreturned: number/** The sweep hit its page cap. What is here is real but incomplete. */truncated: boolean}/*** One meeting, reduced to what the board renders and what it gates on.** `participants` is an access list, not decoration. It is the only thing* standing between a leadership meeting and the rest of the team, because* Fathom's own permissions cannot help here (see config/fathom-keys.ts).* Roster display names rather than Person objects so the snapshot stays* JSON-serialisable for KV.*/export interface MeetingRecord {recordingId: numbertitle: stringdate: stringurl: string/** Roster names of people who were in the room: who may see this meeting. */participants: string[]/** Outsiders, named for context. Never viewers. */externals: string[]/** Internal addresses matching nobody. A question for a human, not a viewer. */unmatchedParticipants: string[]items: MeetingItem[]}export interface MeetingItem {text: string/** Roster name. Null does not mean unowned; check the other two first. */owner: string | null/** External label when an outsider owns it; null otherwise. */externalOwner: string | null/*** A name Fathom gave that the board cannot resolve.** Distinct from unowned, and collapsing the two would lose the difference* between "nobody was named" and "somebody was named and we do not know who".* The first is work looking for an owner; the second is a config gap. All* three owner fields null means genuinely unowned.*/unknownOwner: string | null/** Deep link to the moment it was said. */playbackUrl: string | null}export interface LeadWindow {/** True when the page cap was hit: the count is a floor, not a total. */truncated: booleanpages: number/** Days of history included. Null means no floor: everything ever opened. */sinceDays: number | null}export interface StageCount {id: stringname: stringcount: numberclass: WorkClass | 'ours' | 'waiting' | 'vendor' | 'parked' | 'terminal'}export async function collect(env: Record<string, string | undefined>,days = 60,): Promise<Snapshot> {const syncedAt = new Date().toISOString()// Windowed to the same span as the Plinth pull, so the board describes one period// rather than two. Without a floor this returns thousands of rows: the email-to-lead// automation makes a lead out of every security alert, call log and chat, and// nothing closes them. A lead untouched for eighteen months is debt, not// pipeline, and counting it as pipeline makes every other number meaningless.let leadWindow: LeadWindow = { truncated: false, pages: 0, sinceDays: days }const leads = await safely(async () => {const http = new BitrixClient({ webhookUrl: env.BITRIX_WEBHOOK_URL ?? '', debug: env.DEBUG === 'true' })const res = await new LeadsResource(http).listOpen({ sinceDays: days })leadWindow = { truncated: res.truncated, pages: res.pages, sinceDays: res.sinceDays }return res.leads}, [] as Lead[])// Declared outside `safely` for the same reason leadWindow is: a failed sweep// must leave the window describing a failure, not asserting a clean one.let orderWindow: OrderWindow = {lines: [],truncated: false,pages: 0,// total 0 keeps the coverage guard silent when the Plinth fetch failed: that// failure is already reported as failed, and reporting it a second time as a// coverage gap would describe one outage as two problems.assignmentFields: { production: 0, accountOwner: 0, total: 0 },}const orderLines = await safely(async () => {const client = new PlinthClient({ token: env.PLINTH_API_KEY ?? '', debug: env.DEBUG === 'true' })const to = new Date()const from = new Date(to.getTime() - days * 86_400_000)orderWindow = await new OrdersResource(client).linesInWindow(iso(from), iso(to))return orderWindow.lines}, [] as OrderLine[])const tasks = await safely(async () => {const http = new BitrixClient({ webhookUrl: env.BITRIX_WEBHOOK_URL ?? '', debug: env.DEBUG === 'true' })return new TasksResource(http).listOpen()}, [] as Task[])// Hydrated, because the thin index carries no seriesId, milestoneId or// dueDateChanged, and without those you cannot collapse recurrence, link a// to-do to its rock, or spot a due date that has moved four times. ~2s for 40// rows, which is a sync cost, not a page-load cost.const ninety = await safely(async () => {const client = clientFromEnv(env)const snap = await client.companySnapshot({ detailed: true })return snap.todos}, [] as Todo[])// No credential, by design: the tracker is read from Google's public CSV// endpoint because the document is shared by link. That is what lets the// Worker cron read it at all, and it is stated in config/content-tracker.ts// because it is also a fact about who else can read it.const sheetFetch = await safely(() => fetchTracker(TRACKER_SHEET_ID, TRACKER_TABS),{ tasks: [] as TrackerTask[], tabs: [], failedTabs: [] } as Awaited<ReturnType<typeof fetchTracker>>,)// Meetings: one fetch per configured key. Keys buy coverage, not gating: each// returns its owner's recordings plus whatever Fathom shares with that user,// by a mechanism the API does not expose. The union is deduplicated by// recording id below. See config/fathom-keys.ts.//// Everything the keys can see lands in the snapshot, including meetings most// viewers must never be shown. The gate is at render time, per viewer.const fathomKeys = FATHOM_KEYS.map((k) => ({ ...k, key: env[k.secret] })).filter((k): k is typeof k & { key: string } => Boolean(k.key),)const fathomConfigured = fathomKeys.length > 0// One key's failure must not erase the others, so each key is caught on its// own. A single try around the loop would blank meetings for everyone the// first time one key is revoked, and name no cause.//// The other half of that trade is the prime invariant. A swallowed per-key// failure would make a key that cannot reach someone's meetings look exactly// like a person who recorded none, and one of those means free capacity. So// a dark key is never silent: it rides in `fathomSources`, the source pill reads// `partial`, and every surface that lists commitments says a key is dark.const fathomSources: FathomSourceStatus[] = []const meetings: SourceResult<MeetingRecord[]> = fathomConfigured? await (async () => {const since = new Date(Date.now() - FATHOM_WINDOW_DAYS * 86_400_000).toISOString()const byId = new Map<number, MeetingRecord>()for (const { owner, key } of fathomKeys) {try {const window = await new MeetingsResource(new FathomClient({ apiKey: key, owner }),).listSince(since)for (const m of window.meetings) {// Two keys can return the same meeting. Keyed by recording id so it// is stored once; the participant list is a property of the meeting,// not of whoever fetched it.if (byId.has(m.recording_id)) continueconst people = participantsOf(m.calendar_invitees)byId.set(m.recording_id, {recordingId: m.recording_id,title: m.title,date: m.recording_start_time ?? m.created_at,url: m.url,participants: people.roster.map((x) => x.name),externals: people.external,unmatchedParticipants: people.unmatched,items: (m.action_items ?? []).map((item) => {const owned = isUnownedRef(item.assignee)? null: attributeFathomRef(item.assignee, m.calendar_invitees)return {text: item.description,owner: owned?.kind === 'roster' ? owned.person.name : null,externalOwner: owned?.kind === 'external' ? owned.label : null,unknownOwner: owned?.kind === 'unknown' ? owned.label : null,playbackUrl: item.recording_playback_url ?? null,}}),})}// A capped sweep keeps what it has and says so, or the shortfall// reads as a quiet week.fathomSources.push({owner,ok: true,error: null,returned: window.meetings.length,truncated: window.truncated,})} catch (err) {fathomSources.push({owner,ok: false,error: errorMessage(err),returned: 0,truncated: false,})}}const data = [...byId.values()].sort((a, b) => Date.parse(b.date) - Date.parse(a.date))// Every key dark is a failed source, not a thin one. Keeping ok:true// there would render an empty list under a live pill, which is the exact// shape the prime invariant exists to forbid.return fathomSources.some((s) => s.ok)? { ok: true, data, error: null }: {ok: false,data,error: fathomSources.map((s) => `${s.owner}: ${s.error}`).join('; '),}})(): // Nobody was asked. Empty because unconfigured, which `fathomConfigured`// is what distinguishes from "we looked and there are no meetings".{ ok: true, data: [] as MeetingRecord[], error: null }const assignmentWarnings = orderLines.ok? normalizeAssignments(orderLines.data, orderWindow.assignmentFields): []return {syncedAt,mode: 'live',fixtureCapturedAt: null,leads,orderLines,tasks,ninety,ninetySpread: ninety.ok ? assignmentSpread(ninety.data) : null,sheet: { ok: sheetFetch.ok, data: sheetFetch.data.tasks, error: sheetFetch.error },sheetTabs: { ok: sheetFetch.data.tabs, failed: sheetFetch.data.failedTabs },leadWindow,orderWindow,assignmentWarnings: [...new Set(assignmentWarnings)],meetings,fathomConfigured,fathomSources,}}/*** Map raw Plinth account labels onto roster names, and judge field coverage.** Assignments arrive on the same orders.fetch response as the orders, order* level and per product line. Two normalizations already happened in* plinth-client: the `--` sentinel became null, and a per-line value beat the* order-level one. What happens here is the roster join. peopleBoard and myDay compare* `l.productionOwner === p.name`, so a raw label ("Marco-Acct7-Desk") has to* become a name before the snapshot leaves this file, or every person reads as* holding nothing. An unmapped label is surfaced every run, never silently* rendered raw.** The coverage guard catches assignment data that quietly stops arriving,* which otherwise looks like "nobody has much work" rather than like a break.* Zero coverage is reported as missing, never as zero.** Mutates `lines` in place.*/export function normalizeAssignments(lines: OrderLine[],coverage: AssignmentFieldCoverage,): string[] {const warnings: string[] = []for (const line of lines) {for (const raw of [line.productionOwner, line.accountOwner]) {if (raw !== null && isUnmappedAccount(raw)) {warnings.push(`Unmapped Plinth account "${raw}". Add it to config/plinth-people.ts. ` +`Rendering a best-effort cleanup of the label until then.`,)}}line.productionOwner = displayName(line.productionOwner)line.accountOwner = displayName(line.accountOwner)}if (coverage.total > 0) {const fields = [['production_owner', coverage.production],['account_owner', coverage.accountOwner],] as constfor (const [field, n] of fields) {const pct = Math.round((n / coverage.total) * 100)if (n === 0) {warnings.push(`Plinth returned ${field} on 0 of ${coverage.total} fetched lines. The field arrived ` +`2026-09-02 and has stopped arriving. Per-person order load is MISSING, not zero. ` +`Ask the Plinth maintainer before reading anyone here as free.`,)} else if (pct < 50) {warnings.push(`${field} present on only ${n} of ${coverage.total} lines (${pct}%). Lines without it ` +`render unassigned, so per-person counts are a floor, not a total.`,)}}}return warnings}async function safely<T>(fn: () => Promise<T>, fallback: T): Promise<SourceResult<T>> {try {return { ok: true, data: await fn(), error: null }} catch (err) {return { ok: false, data: fallback, error: errorMessage(err) }}}const iso = (d: Date) => d.toISOString().slice(0, 10)// --- derived views ----------------------------------------------------------/*** Where the live work sits, by product stage. Feeds the stage bars.** Product status throughout: the key, the label and the classification. The* order and product layers share status names with different ids, so mixing* them gives wrong counts and wrong colours with nothing on the chart to show* it. Lines whose order has shipped are left out, because a stage bar is a* picture of what is still in the building.*/export function productStages(lines: OrderLine[]): StageCount[] {const live = lines.filter((l) => !isOrderPastFulfilment(l.orderStatus, l.orderStatusId || null))const counts = new Map<string, number>()for (const l of live) counts.set(l.productStatus, (counts.get(l.productStatus) ?? 0) + 1)return [...counts.entries()].map(([name, count]) => ({ id: name, name, count, class: classifyProduct(name) })).sort((a, b) => b.count - a.count)}export interface Headline {ourMove: numberwaitingOnClient: numberatVendor: numberdesignQueue: number/*** Leads that are genuinely still in play: not noise, and not in a terminal* stage (Junk, Reorder Completed, Converted Lead, Out of Scope, Unconverted).* Terminal is not noise; it is completed work, and on live data it can be* most of the list. Counting completed work as pipeline is the same error as* counting it as workload, one column over.** A Converted Lead is the worst of them: it became an order, so counting it* here double-counts the same job in two systems.*/realLeads: number/** Excluded as automation exhaust. Always shown; silent filtering is lying. */noiseLeads: number/** Excluded as finished. Also always shown: these are real, just not open. */completedLeads: numberneedsReply: numberunknownStatuses: string[]/*** Ninety commitments. Null when the source failed, never 0, because 0 means* "everything is done" and a dead source means "we cannot see".*/commitments: number | null/** Rows behind those commitments. Higher than `commitments` when work is shared. */assignments: number | null/** Commitments carried by more than one person. */sharedCommitments: number | null/** Open to-dos past due and past at least one review gate. */lateTodos: number | null/*** Lead stages present in the data but absent from config/bitrix-stages.ts,* with counts. Every one of these defaults to 'ours' and therefore inflates* `ourMove`. Non-empty means the headline is partly made of unclassified rows* and must not be presented as team workload without saying so.*/unknownLeadStages: { id: string; count: number }[]/** How many of `ourMove`'s leads sit in an unclassified stage. */ourMoveUnclassified: number}export function headline(snap: Snapshot): Headline {const lines = snap.orderLines.dataconst leads = snap.leads.dataconst real = leads.filter((l) => !l.noise)// Product status decides, gated on the order not having shipped. See// classifyLine's note for why neither layer alone is right.const byClass = (c: string) => lines.filter((l) => classifyLine(l) === c).lengthconst leadOurs = real.filter((l) => classifyLead(l.statusId, l.semantic) === 'ours').length// Not noise, but finished. Held separately from `real` so it can be shown// rather than silently subtracted, the same rule the noise count follows.const completed = real.filter((l) => classifyLead(l.statusId, l.semantic) === 'terminal')const stillOpen = real.filter((l) => classifyLead(l.statusId, l.semantic) !== 'terminal')return {ourMove: byClass('ours') + leadOurs,waitingOnClient: byClass('client') + real.filter((l) => classifyLead(l.statusId, l.semantic) === 'waiting').length,atVendor: byClass('vendor'),designQueue: lines.filter((l) => isLineDesignQueue(l)).length,realLeads: stillOpen.length,noiseLeads: leads.length - real.length,completedLeads: completed.length,// "Client Replied": they are waiting on us. Always surfaces first.needsReply: real.filter((l) => stageName(l.statusId) === 'Client Replied').length,unknownStatuses: [...new Set(lines.map((l) => l.orderStatus).filter((s) => !isKnownOrderStatus(s)))],commitments: snap.ninety.ok ? (snap.ninetySpread?.commitments ?? 0) : null,assignments: snap.ninety.ok ? (snap.ninetySpread?.assignments ?? 0) : null,sharedCommitments: snap.ninety.ok ? (snap.ninetySpread?.sharedCommitments ?? 0) : null,lateTodos: snap.ninety.ok? snap.ninety.data.filter((t) => isActuallyLate(t.dueDate, new Date())).length: null,unknownLeadStages: unknownStageIds(real.map((l) => l.statusId)),ourMoveUnclassified: real.filter((l) => classifyLead(l.statusId, l.semantic) === 'ours' && !LEAD_STAGES.some((s) => s.id === l.statusId),).length,}}export interface PersonRow {name: stringrole: string/** Orders where this person owes the next move. Null when not covered. */plinthOurMove: number | null/** Orders sitting with the client. Context, not bandwidth. */plinthWaiting: number | null/** Open Bitrix tasks, junk excluded. Null when not covered. */taskCount: number | nulltaskOverdue: number | null/*** Open Ninety to-dos owned by this person. Null when the source failed.* Without them, people who hold no order lines read as idle on the panel* titled "workload by person".*/todos: number | null/** Of those, past due and past a review gate. */todosLate: number | null/*** Content Creation tracker rows this person owes a move on. Null when the* sheet failed or does not cover them.** This work lives nowhere else: Ninety carries one rollup row where the* sheet carries a dozen jobs, so without it the designers look far emptier* than they are.*/sheetOurMove: number | null/** Finished by them and waiting on the launch step. Context, not workload. */sheetWaiting: number | null/** Oldest thing they owe, in days. */oldestDays: number | null/** Sources that cover this person, for the row annotation. */coverage: string[]coverageNote: string | null/** True when nothing can see this person. Must never render as a zero. */untracked: boolean}export interface PeopleBoard {rows: PersonRow[]/** Tasks excluded as automation exhaust. Shown, never hidden. */junkTasks: number/** Plinth accounts holding work that belong to nobody on the roster. */offRosterAccounts: string[]/*** The meeting-commitment window, in days. This board carries no meeting* content and no per-person commitment counts: it is company-visible, and* even a count ("Leadership sync: 4 items") discloses that a meeting* happened and who was left out. Meeting content renders in exactly one* place, `myDay()`, filtered by `meetingsFor()`. One renderer is a boundary* you can test.*/commitmentWindowDays: number/*** The Content Creation tracker, in numbers the page can state.** `unclassified` is separate from every other count on purpose: see the* comment on trackerCounts().*/tracker: TrackerCounts/*** The Bitrix task join, as numbers instead of a claim about the method. A* description of a join can read as working while it attributes nothing; a* count cannot.** `offRoster` is expected and not a gap: the directory holds far more accounts* than the roster, and most are already inactive in Bitrix. What it is for* is the opposite failure: a roster id going stale and that person's tasks* quietly joining this bucket, which looks exactly like having nothing to do.*/taskJoin: {attributed: numberreal: numberjunk: numberoffRoster: { id: string; tasks: number }[]/** Roster people covered by Bitrix with no id pinned. Should be empty. */unpinned: string[]}}export interface TrackerCounts {rows: numberours: numberwaiting: numberdone: numberunclassified: numberunknownStatuses: string[]unmappedOwners: string[]/** Rows naming more than one owner. Counted per person, so this is context. */shared: number}/*** The tracker in one place, because two surfaces need the same numbers and a* second implementation is a second answer.** Unclassified is not folded into anything (rule 2): a default that stands in* for knowledge must be surfaced every run. Rows with a blank or unknown* Status are counted apart and named on the page.*/export function trackerCounts(snap: Snapshot): TrackerCounts {const rows = snap.sheet.dataconst isClass = (t: TrackerTask, c: TrackerClass | null) => classifyTrackerStatus(t.status) === creturn {rows: rows.length,ours: rows.filter((t) => isClass(t, 'ours')).length,waiting: rows.filter((t) => isClass(t, 'waiting')).length,done: rows.filter((t) => isClass(t, 'terminal')).length,unclassified: rows.filter((t) => isClass(t, null)).length,unknownStatuses: [...new Set(rows.filter((t) => isClass(t, null)).map((t) => t.status.trim() || '(blank)')),].sort(),unmappedOwners: unmappedTrackerOwners(rows.flatMap((t) => t.owners)),shared: rows.filter((t) => t.shared).length,}}/*** The bird's-eye view: one row per person, every source folded in.** The rule that keeps this honest: a person no source can see renders as* "not tracked", never as 0. Those look identical at a glance and only one of* them means free capacity.** Partial coverage is normal and is annotated quietly rather than flagged.*/export function peopleBoard(snap: Snapshot): PeopleBoard {const lines = snap.orderLines.dataconst { real: realTasks, junk } = partitionTasks(snap.tasks.data)const offRoster = new Set<string>()for (const l of lines) {for (const label of [l.productionOwner, l.accountOwner]) {if (label && !personByName(label)) offRoster.add(label)}}// Ninety is company-wide, so every roster person is covered by it.const todosByPerson = todoLoad(snap.ninety.data)// Tracker rows, split by what the Status column means. A row with two owners// counts for both of them here: it is one commitment but two people have to// show up for it, and a per-person board is asking who has to show up.// Company totals must not sum these columns for the same reason Ninety's// assignments and commitments are different numbers.const sheetBy = new Map<string, { ours: number; waiting: number }>()for (const t of snap.sheet.data) {const cls: TrackerClass | null = classifyTrackerStatus(t.status)if (cls !== 'ours' && cls !== 'waiting') continuefor (const owner of t.owners) {const person = personFromTrackerOwner(owner)if (!person) continueconst cur = sheetBy.get(person.name) ?? { ours: 0, waiting: 0 }cur[cls]++sheetBy.set(person.name, cur)}}const rows: PersonRow[] = MERIDIAN_PEOPLE.map((p: Person) => {const coversPlinth = p.coveredBy.some((c) => c.startsWith('plinth-'))const coversTasks = p.coveredBy.includes('bitrix-tasks')const coversSheet = p.coveredBy.includes('content-tracker')const theirSheet = coversSheet && snap.sheet.ok ? (sheetBy.get(p.name) ?? { ours: 0, waiting: 0 }) : nullconst theirTodos = snap.ninety.ok? (todosByPerson.find((t) => t.name === p.name) ?? { open: 0, late: 0 }): nullconst mine = coversPlinth? lines.filter((l) => l.productionOwner === p.name || l.accountOwner === p.name): []const ourMove = mine.filter((l) => classifyLine(l) === 'ours')const waiting = mine.filter((l) => classifyLine(l) === 'client')// By id: `responsibleId` is Bitrix's numeric user id, so a name lookup on// it matches nobody. Roster ids are pinned from user.get.const theirTasks = coversTasks? realTasks.filter((t) => personFromBitrix(t.responsibleId)?.name === p.name): []const ages = [...ourMove.map((l) => l.ageDays),...theirTasks.map((t) => t.ageDays),].filter((d): d is number => d !== null)return {name: p.name,role: p.role,plinthOurMove: coversPlinth ? ourMove.length : null,plinthWaiting: coversPlinth ? waiting.length : null,taskCount: coversTasks ? theirTasks.length : null,taskOverdue: coversTasks ? theirTasks.filter((t) => t.overdue).length : null,oldestDays: ages.length ? Math.max(...ages) : null,todos: theirTodos ? theirTodos.open : null,todosLate: theirTodos ? theirTodos.late : null,sheetOurMove: theirSheet ? theirSheet.ours : null,sheetWaiting: theirSheet ? theirSheet.waiting : null,coverage: p.coveredBy.map((c) => SOURCE_LABELS[c]),coverageNote: p.coverageNote ?? null,untracked: p.coveredBy.length === 0,}})return {rows,junkTasks: junk.length,tracker: trackerCounts(snap),taskJoin: {attributed: realTasks.filter((t) => personFromBitrix(t.responsibleId)).length,real: realTasks.length,junk: junk.length,offRoster: offRosterBitrixIds(realTasks.map((t) => t.responsibleId)),unpinned: unpinnedBitrixPeople(),},offRosterAccounts: [...offRoster].sort(),commitmentWindowDays: COMMITMENT_WINDOW_DAYS,}}/*** One actionable row per order line: the number a person can paste into Plinth,* and the name of whoever is holding it.** A count of "44 our move" tells leadership the shape of the day; it tells a* designer nothing they can act on. A list of order numbers with their name* against them does.** Rules carried over from the rest of the file, restated because they decide* what is in this list:* - Product status, gated on the order not having shipped. The product row* says what the work is; the order says whether it is still here.* - Only 'ours'. A line waiting on the client is a follow-up, not a queue.* - `designer: null` renders as "unassigned", never as a blank cell. A blank* reads as "no designer needed" and this is the opposite of that.*/export interface OrderQueueRow {/** "MD2639582". Pasteable into Plinth. See displayOrderNumber(). */orderNumber: stringorderId: number/*** Null when the source has no product name, which is the case whenever the* row came from an order-level export. Renders as "product not in export",* never as the order number repeated back. A column that echoes the column* beside it is noise wearing the costume of data.*/product: string | nullquantity: number/** The real one. Product-level. */status: string/** Designer (Plinth production_owner). Null when unassigned or not reported. */designer: string | null/** CS rep (Plinth account_owner). Null when uncovered. */accountOwner: string | nullcustomer: stringcompany: string | nullageDays: number | nulldesignQueue: boolean}export function orderQueue(lines: OrderLine[]): OrderQueueRow[] {return lines.filter((l) => classifyLine(l) === 'ours').map((l) => {const orderNumber = displayOrderNumber(l.orderId, l.placedAt)const title = l.productTitle?.trim() ?? ''return {orderNumber,orderId: l.orderId,// An order-level export puts the order number in the title slot. Echoing// it back as a product name is worse than admitting we do not have one.product: !title || title === orderNumber || title === String(l.orderId) ? null : title,quantity: l.quantity,status: l.productStatus || l.orderStatus,designer: l.productionOwner,accountOwner: l.accountOwner,customer: l.customer,company: l.company,ageDays: l.ageDays,designQueue: isLineDesignQueue(l),}})// Oldest first. The top of this list is what is actually rotting..sort((a, b) => (b.ageDays ?? -1) - (a.ageDays ?? -1))}/** The same queue, grouped under the person holding it. Unassigned last. */export function orderQueueByDesigner(lines: OrderLine[]): {designer: string | nullrows: OrderQueueRow[]}[] {const rows = orderQueue(lines)const by = new Map<string, OrderQueueRow[]>()for (const r of rows) {const key = r.designer ?? '\u{10FFFF}unassigned'by.set(key, [...(by.get(key) ?? []), r])}return [...by.entries()].map(([key, list]) => ({designer: key.startsWith('\u{10FFFF}') ? null : key,rows: list,})).sort((a, b) => {if (a.designer === null) return 1if (b.designer === null) return -1return b.rows.length - a.rows.length})}/*** Open to-dos per person, using Meridian's review-gate rule rather than raw overdue.** Not raw `overdue`, because nothing may be marked complete outside a Monday or Friday* review, by policy. So raw days-overdue inflates every weekend and resets every* Monday, which trains people to ignore the number. `isActuallyLate` means* overdue and at least one review has passed since it came due.** And the thing that must reach the briefing prompt: an open to-do does not mean* unfinished work. It may be done and waiting for sign-off. Any sentence written* from this data has to survive that being true.*/export interface TodoLoad {name: stringopen: numberlate: numberoldestGatesMissed: number}export function todoLoad(todos: Todo[], now = new Date()): TodoLoad[] {const by = new Map<string, Todo[]>()for (const t of todos) {// Roster-resolved, not name-matched. Ninety spells people differently from// config ("Samuel Whitaker" vs "Sam Whitaker"), so an equality join would// drop people to zero without saying so. Unmapped owners keep their// Ninety spelling here and are reported via dataHealth.unmappedTodoOwners.const key = personFromNinety(t.owner.id, t.owner.name)?.name ?? t.owner.nameby.set(key, [...(by.get(key) ?? []), t])}return [...by.entries()].map(([name, list]) => ({name,open: list.length,late: list.filter((t) => isActuallyLate(t.dueDate, now)).length,oldestGatesMissed: list.reduce((m, t) => Math.max(m, reviewGatesMissed(t.dueDate, now)), 0),})).sort((a, b) => b.late - a.late || b.open - a.open)}export function agedLeads(leads: Lead[], now = new Date()): (Lead & { ageDays: number })[] {return leads.filter((l) => !l.noise && l.createdAt).map((l) => ({...l,ageDays: Math.floor((now.getTime() - Date.parse(l.createdAt!)) / 86_400_000),})).sort((a, b) => b.ageDays - a.ageDays)}