view-model.ts
Language TypeScript62.7 KB1566 lines
/*** Everything the six surfaces render, derived once from a Snapshot.** `collect.ts` decides what the numbers are. This file decides what they look* like: labels, colors, bar widths, the sentences in the briefing. Keeping the* split means a classification argument is settled in one file and a layout* argument in another, and neither can quietly change the other's answer.** The prime invariant, restated because every function here can break it: a person or* a metric that no source covers must come out as `null`, and must render as* "not tracked". Never 0. A zero means free capacity and an absent number does* not, and at a glance they are the same shape.*/import { classifyOrder, isOrderDesignQueue, type OrderLine } from '@meridian/plinth-client'import { partitionTasks, type Lead, type Task } from '@meridian/bitrix-client'import { isActuallyLate, localDay, reviewGatesMissed, MERIDIAN_TZ, type Todo } from '@meridian/ninety-client'import { classify as classifyLead, stageName } from '../../../config/bitrix-stages.js'import {MERIDIAN_PEOPLE,personFromBitrix,personFromNinety,personFromTrackerOwner,uncoveredByBitrix,unpinnedBitrixPeople,} from '../../../config/meridian-people.js'import { classifyTrackerStatus, TRACKER_TITLE } from '../../../config/content-tracker.js'import { COMMITMENT_WINDOW_DAYS } from '../../../config/fathom-people.js'import {agedLeads,orderQueueByDesigner,peopleBoard,productStages,trackerCounts,type Headline,type OrderQueueRow,type PeopleBoard,type MeetingRecord,type PersonRow,type Snapshot,type StageCount,} from './collect.js'import { C, CLASS_COLOR, UNTRACKED_FILL } from './theme.js'import { plural } from './html.js'import { delta, previousRun, type Delta, type HistoryRow } from './history.js'import { COMPANY_VIEWER, type Viewer } from './viewer.js'// --- sources ----------------------------------------------------------------export type SourceTone = 'live' | 'degraded' | 'absent' | 'failed'export interface SourceStatus {name: stringcovers: string/** Short label for the pill and the health table. */status: stringtone: SourceTonenote: string}const TONE_COLOR: Record<SourceTone, string> = {live: C.green,degraded: C.orange,absent: C.gray,failed: C.magenta,}export const toneColor = (t: SourceTone): string => TONE_COLOR[t]const daysSince = (iso: string | null): number | null =>iso ? Math.floor((Date.now() - Date.parse(iso)) / 86_400_000) : null/*** The Bitrix join in numbers a reader can check against the page.** Off-roster holders are stated and explicitly not called a gap: almost every* Bitrix account is out of scope for this board, and most of those are already* inactive in Bitrix itself. Reporting them as unmapped would be the* `unmappedNinetyOwners()` treatment applied where it does not belong: a* permanent warning about work nobody intends to show.*/function describeTaskJoin(snap: Snapshot): string {const { real, junk } = partitionTasks(snap.tasks.data)const attributed = real.filter((t) => personFromBitrix(t.responsibleId)).lengthconst off = real.length - attributedconst unpinned = unpinnedBitrixPeople()// Somebody Bitrix does not cover is a third state. `unpinnedBitrixPeople()`// only reports people who claim bitrix-tasks coverage and lack an id: the// case where tasks cannot attribute. A person with no id and no claim (a new// hire, say) is not that case, and without this line the page would claim// every roster id is pinned about a person it never looked up. Their tasks// read "not tracked" on their row; this is the surface that says why.const uncovered = uncoveredByBitrix()const pinned = MERIDIAN_PEOPLE.filter((p) => p.bitrixUserId).lengthreturn (`${attributed} of ${real.length} open tasks attributed to the roster by Bitrix user id; ` +`${junk.length} withheld as automation exhaust, test and demo rows. ` +(off? `${off} held by ${off === 1 ? 'an account' : 'accounts'} outside the roster. That is expected, not a gap: ` +`the directory carries the whole company's history and Bitrix marks most of it inactive. `: '') +(unpinned.length? `NO BITRIX ID PINNED for ${unpinned.join(', ')}, so their tasks cannot attribute at all.`: `${pinned} of ${MERIDIAN_PEOPLE.length} roster ids are pinned.`) +(uncovered.length? ` Bitrix does not cover ${uncovered.join(', ')} at all: no user id is known, so their ` +`task counts read as not tracked rather than as zero. Fill it with a Bitrix user.get ` +`lookup.`: ''))}/*** The tracker, said plainly, including the two things it cannot tell us.** How stale it is: unknown, and that is stated rather than skipped. Google's* public CSV endpoint sends no `Last-Modified`, and the document's real* `modifiedTime` needs the Drive API and a credential the Worker does not have.* So the one input most likely to go quietly stale is the one we can say the* least about. Leaving the sentence out would let a reader assume it is current;* "not tracked" is the honest rendering of an absence, here as everywhere.*/function describeTracker(snap: Snapshot): string {const t = trackerCounts(snap)const tabs = snap.sheetTabs.ok.map((x) => x.label).join(', ')const parts = [`${t.ours} open, ${t.waiting} ready to launch, ${t.done} launched across ${snap.sheetTabs.ok.length} tab${snap.sheetTabs.ok.length === 1 ? '' : 's'} (${tabs}).`,]if (snap.sheetTabs.failed.length) {parts.push(`${snap.sheetTabs.failed.map((f) => `${f.label} (${f.reason})`).join('; ')} could not be read, so this is a PARTIAL view of the sheet.`,)}if (t.unclassified) {parts.push(`${t.unclassified} row${t.unclassified === 1 ? '' : 's'} counted as NEITHER open nor done because the Status cell reads ${t.unknownStatuses.join(', ')}: unclassified, not assumed.`,)}if (t.unmappedOwners.length) {parts.push(`Owner${t.unmappedOwners.length === 1 ? '' : 's'} matching nobody on the roster: ${t.unmappedOwners.join(', ')}. Their rows are on no one's board.`,)}parts.push(`Hand-maintained, and read from the sheet's public CSV export, which sends no last-modified, so HOW FRESH THIS IS cannot be measured here and is not tracked. ${TRACKER_TITLE}.`,)return parts.join(' ')}/*** One row per source, in the order a reader should audit them.** A failed source reports as failed with its error text. The whole point is that* zero and broken never look the same. A source that died and a source with* nothing in it produce the same empty chart, and only one of them is a problem.*/export function sourceStatuses(snap: Snapshot): SourceStatus[] {// A fixture run has not talked to anything. Reporting "live" over a frozen// export is the exact lie this board exists to prevent, so every pill is// rewritten rather than trusted.if (snap.mode === 'fixtures') {const captured = snap.fixtureCapturedAtconst age = daysSince(captured)const staleness =age === null ? '' : ` Captured ${age} days ago; some of these orders have moved on since.`// Failure outranks sample. A source that threw must read as failed even in// a fixture run: "sample data" over a dead source hides a real break behind// a reassuring label, which is the same masking bug one level down.const failed: Record<string, string | null> = {Plinth: snap.orderLines.ok ? null : snap.orderLines.error,'Bitrix leads': snap.leads.ok ? null : snap.leads.error,'Bitrix tasks': snap.tasks.ok ? null : snap.tasks.error,'Ninety (90)': snap.ninety.ok ? null : snap.ninety.error,'Content Creation tracker': snap.sheet.ok ? null : snap.sheet.error,}const sample = (name: string, covers: string, note: string): SourceStatus => {const err = failed[name]if (err) {return {name,covers,status: 'failed',tone: 'failed',note: `Request failed: ${err}. Missing from this page, not zero.`,}}return { name, covers, status: 'sample data', tone: 'degraded', note }}return [sample('Plinth', 'orders, product lines',// Sliced, not localized, and that is not the bug it looks like.// SEED_EXPORTED_AT is a date wearing an instant's clothes (midnight// UTC, meaning "the export was taken that day"). Midnight UTC is 7 PM// the previous evening in Chicago, so running it through localDay()// would render the day before and misstate when the export was taken. Converting a date-only value through a timezone is the mirror// image of the bug dates.ts exists to prevent, and just as wrong.`Frozen export from ${captured?.slice(0, 10) ?? 'an earlier date'}, not a live query.${staleness} ` +`An order shown here as open may already be shipped.`),sample('Bitrix leads', 'lead pipeline', 'Invented leads shaped like the real ones. No request was made.'),sample('Bitrix tasks', 'internal tasks', 'Sample tasks, including the junk rows, because the junk is the point.'),sample('Plinth assignments', 'who owns which order line', 'Assignments come from the same frozen export.'),sample('Fathom', 'meeting commitments', 'Static seed. Unchanged in fixture and live runs alike.'),sample('Ninety (90)', 'company to-dos','Sample to-dos shaped like the live company sweep, including one commitment carried by three people.'),]}// A dedicated row, not a note on the Plinth row. Assignments ride the same// request as the orders, so they share its freshness, but they can degrade on// their own: Plinth can answer perfectly and still stop sending these two fields,// which is why coverage is counted and shown rather than assumed.const cov = snap.orderWindow.assignmentFieldsconst assignmentBase = { name: 'Plinth assignments', covers: 'who owns which order line' }let assignmentRow: SourceStatusif (!snap.orderLines.ok) {assignmentRow = {...assignmentBase,status: 'failed',tone: 'failed',note: `Assignments ride the Plinth orders fetch, which failed: ${snap.orderLines.error}. Per-person load is missing, not zero.`,}} else if (cov.total === 0) {assignmentRow = {...assignmentBase,status: 'no lines',tone: 'degraded',note: 'The order window returned no product lines, so whether the assignment fields are still being sent cannot be judged this run.',}} else if (cov.production === 0 && cov.accountOwner === 0) {assignmentRow = {...assignmentBase,status: 'fields missing',tone: 'failed',note: `orders.fetch returned neither production_owner nor account_owner on any of the ${cov.total} fetched lines. They arrived 2026-09-02 and have stopped arriving. Per-person load is missing, not zero.`,}} else {const worst = Math.min(cov.production, cov.accountOwner)const pct = Math.round((worst / cov.total) * 100)assignmentRow =pct < 100? {...assignmentBase,status: `${pct}% of lines`,tone: 'degraded',note: `production_owner on ${cov.production} and account_owner on ${cov.accountOwner} of ${cov.total} lines. Lines without a field render unassigned, so per-person counts are a floor.`,}: {...assignmentBase,status: 'live',tone: 'live',note: 'production_owner and account_owner arrive on the same orders.fetch response as the orders (added by Plinth 2026-09-02), order level and per product line, as fresh as the Plinth pill. The manual CSV export is retired.',}}// Which keys came back short, named rather than counted. A pill that said// "1 key failed" would send the reader to the logs; a pill that says whose// key it was sends them to the person who can re-issue it.const fathomDark = snap.fathomSources.filter((k) => !k.ok)const fathomCapped = snap.fathomSources.filter((k) => k.ok && k.truncated)return [{name: 'Plinth',covers: 'orders, product lines',status: snap.orderLines.ok ? 'live' : 'failed',tone: snap.orderLines.ok ? 'live' : 'failed',note: snap.orderLines.ok? 'Order lines are classified on the PRODUCT status, and only counted while the order has not moved past Part Dispatched. Both fields go stale in opposite directions: some orders read "On Press" with artwork still in review, and product rows still read "File Check" on orders that have shipped.': `Request failed: ${snap.orderLines.error}. Every order number on this page is missing, not zero.`,},{name: 'Bitrix leads',covers: 'lead pipeline',status: snap.leads.ok ? 'live' : 'failed',tone: snap.leads.ok ? 'live' : 'failed',note: snap.leads.ok? `${snap.leads.data.filter((l) => l.noise).length} automated rows excluded as noise: security emails, call logs, website chat. The excluded count is always shown; silent filtering is how a dashboard starts lying.`: `Request failed: ${snap.leads.error}. No lead number on this page is real.`,},{name: 'Bitrix tasks',covers: 'internal tasks',status: snap.tasks.ok ? 'live' : 'failed',tone: snap.tasks.ok ? 'live' : 'failed',// Counts, not a description of the method. A sentence describing how the// join works can read as "fine" on a join that attributed nothing; the// numbers cannot.note: snap.tasks.ok ? describeTaskJoin(snap) : `Request failed: ${snap.tasks.error}.`,},{name: 'Content Creation tracker',covers: 'internal design, photo, copy and newsletter work',// Not "live". Every other pill here means "we queried a system of record// just now"; this one means "we read a spreadsheet a person types into".// Borrowing the same word for both would make the least reliable input on// this board look like the most current.status: snap.sheet.ok ? (snap.sheetTabs.failed.length ? 'partial' : 'read') : 'failed',tone: snap.sheet.ok ? (snap.sheetTabs.failed.length ? 'degraded' : 'live') : 'failed',note: snap.sheet.ok ? describeTracker(snap) : `Could not read the sheet: ${snap.sheet.error}.`,},assignmentRow,// Fathom's pill has to say what kind of live. Read `not configured` as// absent rather than as zero: nobody was asked.!snap.fathomConfigured? {name: 'Fathom',covers: 'meeting commitments',status: 'not configured',tone: 'absent' as const,note: 'No Fathom key is set, so meeting commitments read "not tracked" rather than zero. Keys are per user; see config/fathom-keys.ts.',}: !snap.meetings.ok? {name: 'Fathom',covers: 'meeting commitments',status: 'failed',tone: 'failed' as const,note: `Request failed: ${snap.meetings.error}. Meeting commitments are missing from this page, not zero.`,}: {name: 'Fathom',covers: 'meeting commitments',// Partial is its own state, the same as the tracker's failed tabs.// With several keys, "some of the company's meetings were read" is a// real outcome, and calling it `live` would put a clean pill over an// incomplete list: the prime invariant applied to the pill itself.status: fathomDark.length || fathomCapped.length ? 'partial' : 'live',tone: fathomDark.length || fathomCapped.length ? ('degraded' as const) : ('live' as const),note:`${snap.meetings.data.length} meetings in the last ${COMMITMENT_WINDOW_DAYS} days, ` +`from ${snap.fathomSources.length} ${plural(snap.fathomSources.length, 'key')} ` +`(${snap.fathomSources.map((k) => k.owner).join(', ')}). ` +// Most of the roster has handed over no key. Some of their// recordings reach the board through the keys we hold (a key// returns meetings recorded by other people too), but whether all// of them do cannot be known from here, so a person at zero is not// proof of an empty fortnight.`Fathom has no org keys. The board sees the union of what its keys return; ` +`a person showing few or no meetings may simply have recorded ones no key here ` +`can reach, so zero is a floor, not a fact. ` +(fathomDark.length? `${fathomDark.map((k) => `${k.owner}'s key failed: ${k.error}`).join(' ')} ` +`Their meetings are missing from this run, not empty. `: '') +(fathomCapped.length? `${fathomCapped.map((k) => k.owner).join(', ')} hit the page cap, so that ` +`sweep is the newest part of the window and not all of it. `: '') +`EVERY PAGE SHOWS ONLY THE MEETINGS ITS READER ATTENDED. The keys fetch, the ` +`board filters per viewer. Commitments have no completion state, so they are ` +`windowed rather than accumulated.`,},{name: 'Ninety (90)',covers: 'company to-dos',status: snap.ninety.ok ? 'live' : 'failed',tone: snap.ninety.ok ? 'live' : 'failed',note: snap.ninety.ok? `Every non-personal to-do in the company, from one Owner/Admin token via ` +`GET /todos/company. ${describeSpread(snap)} Rocks and issues are not connected yet.`: // The status-specific explanation lives in the client's own error// (rule 1). What this line adds is the part that is true of every// Ninety failure: the numbers are missing, not zero.`Request failed: ${snap.ninety.error} Every commitment number on this ` +`page is missing, not zero.`,},]}/*** The sentence that stops the count being argued about.** A bare row count overstates the work whenever an item is carried by more than* one person, and on live data that can be about a quarter of the rows. Saying both* numbers in the health table means nobody has to discover the difference during* a meeting.*/function describeSpread(snap: Snapshot): string {const s = snap.ninetySpreadif (!s) return ''if (!s.sharedCommitments) return `${s.commitments} commitments, none shared.`return (`${s.assignments} assignments across ${s.commitments} distinct commitments, ` +`${s.sharedCommitments} carried by more than one person, up to ${s.maxOwners}. ` +`Per-person views keep every row; company counts dedupe.`)}// --- headline tiles and their drill-downs ------------------------------------export interface DrillRow {id: string/** "<stage>: <subject>", for the drill table where the stage is the point. */what: string/** The subject alone. The briefing sets its own context, so repeating the* stage there produces "moved to Client Replied: Client Replied: …". */subject: stringwho: stringage: string}export interface Drill {key: stringtitle: stringnote: stringrows: DrillRow[]/** Rows beyond what is shown. Stated, never silently dropped. */more: number}const DRILL_LIMIT = 12const ownerOf = (l: OrderLine): string => l.productionOwner ?? l.accountOwner ?? 'unassigned'const ageOf = (d: number | null): string => (d === null ? '–' : `${d}d`)const orderRow = (l: OrderLine): DrillRow => {const who = l.company ?? l.customer ?? nullconst product = l.productTitle || nullconst subject = [who, product && product !== who ? product : null].filter(Boolean).join(' · ') || 'order line'return {id: String(l.orderId),what: `${l.productStatus}: ${subject}`,subject,who: ownerOf(l),age: ageOf(l.ageDays),}}function leadRow(l: Lead & { ageDays: number }): DrillRow {return {id: `L-${l.id}`,what: `${stageName(l.statusId)}: ${l.title}`,subject: l.company ?? l.title,who: l.company ?? '–',age: `${l.ageDays}d`,}}/** Oldest first: the thing that has waited longest is the thing to look at. */const byAgeDesc = <T extends { age: string }>(rows: T[]): T[] =>[...rows].sort((a, b) => (parseInt(b.age, 10) || 0) - (parseInt(a.age, 10) || 0))function cut(rows: DrillRow[]): { rows: DrillRow[]; more: number } {const sorted = byAgeDesc(rows)return { rows: sorted.slice(0, DRILL_LIMIT), more: Math.max(0, sorted.length - DRILL_LIMIT) }}/** Order statuses that need a decision from a person, not a step in a queue. */const EXCEPTION_STATUSES = /gateway|cancellation|refund/iexport function drills(snap: Snapshot): Record<string, Drill> {const lines = snap.orderLines.dataconst leads = agedLeads(snap.leads.data)const cls = (l: OrderLine) => classifyOrder(l.orderStatus, l.orderStatusId || null)const oursLines = lines.filter((l) => cls(l) === 'ours')const oursLeads = leads.filter((l) => classifyLead(l.statusId, l.semantic) === 'ours')const design = lines.filter((l) => isOrderDesignQueue(l.orderStatus, l.orderStatusId || null))const replied = leads.filter((l) => stageName(l.statusId) === 'Client Replied')const exceptions = lines.filter((l) => EXCEPTION_STATUSES.test(l.orderStatus))const mk = (key: string, title: string, note: string, rows: DrillRow[]): Drill => ({key,title,note,...cut(rows),})return {ours: mk('ours',`Our move · ${oursLines.length + oursLeads.length} items`,`${oursLines.length} order lines from Plinth and ${oursLeads.length} leads from Bitrix. Oldest first.`,[...oursLines.map(orderRow), ...oursLeads.map(leadRow)],),design: mk('design',`Design queue · ${design.length} in artwork`,'The closest proxy we have for open mockups. It is a proxy, not the number; see Data health.',design.map(orderRow),),replied: mk('replied',`Client replied · ${replied.length} lead${replied.length === 1 ? '' : 's'}`,'They are waiting on us and nobody else. This always surfaces first.',replied.map(leadRow),),exceptions: mk('exceptions',`Exceptions · ${exceptions.length} items`,'Payment gateway malfunctions, cancellation requests and refund claims. Each needs a decision, not a task.',exceptions.map(orderRow),),}}// --- person board ------------------------------------------------------------export type LoadBasis = 'ours' | 'all'export interface PersonView extends PersonRow {short: string/** Null when nothing covers them. Renders as "not tracked". */load: number | nullloadNote: stringpct: stringfill: stringdetail: stringaside: string/** Tasks past at least one Monday review gate, Meridian's definition of late. */actuallyLate: number}const firstName = (n: string): string => n.split(' ')[0] ?? nexport function personViews(board: PeopleBoard,tasks: Task[],basis: LoadBasis = 'ours',): PersonView[] {const { real } = partitionTasks(tasks)const lateFor = (name: string): number =>real.filter((t) => personFromBitrix(t.responsibleId)?.name === name && reviewGatesMissed(t.deadline) > 0).lengthconst loadOf = (r: PersonRow): number | null => {if (r.untracked) return nullconst ours = r.plinthOurMove ?? 0const waiting = basis === 'all' ? (r.plinthWaiting ?? 0) : 0// Ninety to-dos and tracker rows are part of the load: they are real work// this person owes a move on, and without them people who hold no order// lines read as idle on the panel called "workload by person".return ours + waiting + (r.taskCount ?? 0) + (r.todos ?? 0) + (r.sheetOurMove ?? 0)}const loads = board.rows.map(loadOf).filter((n): n is number => n !== null)const max = Math.max(1, ...loads)return board.rows.map((r) => {const load = loadOf(r)const late = lateFor(r.name)const parts: string[] = []if (r.plinthOurMove !== null) parts.push(`${r.plinthOurMove} order${r.plinthOurMove === 1 ? '' : 's'}`)else parts.push('orders not tracked')if (r.taskCount !== null) parts.push(`${r.taskCount} task${r.taskCount === 1 ? '' : 's'}`)// Not "commitment": the "I said I would" block on My Day calls Fathom action// items commitments, and these are Ninety to-dos. One label for two sources// makes a reader think one number covers both.if (r.todos !== null) parts.push(`${r.todos} to-do${r.todos === 1 ? '' : 's'}`)if (r.sheetOurMove !== null)parts.push(`${r.sheetOurMove} tracker item${r.sheetOurMove === 1 ? '' : 's'}`)const aside: string[] = []if (r.plinthWaiting) aside.push(`${r.plinthWaiting} waiting on client`)// Finished by them, blocked on the launch step. Aside, never load: counting// it as backlog is how a board makes someone look behind for work they did.if (r.sheetWaiting) aside.push(`${r.sheetWaiting} ready to launch`)// `+`, not `||`: a person with one late Bitrix task must still report their// late Ninety to-dos too.const pastReview = late + (r.todosLate ?? 0)if (pastReview) aside.push(`${pastReview} past a review gate`)if (r.oldestDays !== null) aside.push(`oldest ${r.oldestDays}d`)return {...r,short: firstName(r.name),load,loadNote: r.untracked? 'no covering source': r.plinthOurMove === null? 'tasks + to-dos': 'open items',pct: load === null ? '100%' : `${Math.round((load / max) * 100)}%`,// A hatched bar for partial coverage: it reads as "this number is not the// whole story" without needing the legend.fill:load === null || r.plinthOurMove === null? UNTRACKED_FILL: (r.oldestDays ?? 0) >= 20? C.magenta: C.blue,detail: parts.join(' + '),aside: aside.join(' · '),actuallyLate: late,}})}// --- my day ------------------------------------------------------------------export interface WorkItem {what: stringmeta: stringage: stringcolor: stringlate: boolean/** Which source this row came from. My Day groups by it, per the redesign. */kind: 'order' | 'task' | 'todo' | 'tracker'/** Pasteable identifier when the source has one (order numbers). */id?: string/*** Key for the shared user note on this item: `order:MD26…` / `task:<id>`.* Only orders and Bitrix tasks carry one: they are the rows leadership asks* "why is this sitting" about.*/noteKey?: string}export interface MyDay {person: PersonViewourMove: numberwaiting: numbertasks: number/** Open Ninety to-dos listed on this card. */todos: number/** Content Creation tracker rows this person owes a move on. */tracker: number/*** To-dos counted in the header but deliberately not listed, because this card* does not belong to the viewer. Stated, never absorbed: a header number above* a list that cannot account for it breaks rule 9.*/todosWithheld: number/*** Sources feeding this list that failed. An empty list means there is nothing* to do; a dead source means we cannot see. Those must never read the same,* and the empty state is the only place a reader would find out.*/sourcesDown: string[]items: WorkItem[]/*** Action items from meetings this viewer attended, all of them, not only the* ones assigned to them. See myDay() for the three rules and for the one* consequence: an item assigned to you from a meeting you missed is not here,* because surfacing it would disclose the meeting.*/commitments: {text: stringmeeting: stringdate: stringurl: string/** Roster owner, when the board could resolve one. */owner: string | null/** Outsider owner: a client or vendor. */externalOwner: string | null/** A name Fathom gave that config does not recognise. A gap, not a person. */unknownOwner: string | null/** Nobody named at all. The ones most likely to be dropped. */unowned: boolean}[]/*** False when meetings are not tracked: no Fathom key, or the fetch failed.* An empty `commitments` then means "we did not look", which must not render* as "you have no commitments".*/commitmentsTracked: boolean/*** Set when meetings were read, but not from every configured key.** `commitmentsTracked` is all-or-nothing and cannot express this. With several* keys the common failure is one revoked key, and the result on this card is* a short list, not an empty one, which reads as a quiet fortnight. Null when* every key came back clean.*/commitmentsPartial: string | nulllateCount: number}/*** Whole days between a to-do's due day and today, in Meridian's timezone.** Both sides are YYYY-MM-DD parsed as UTC midnight, so this is a calendar-day* difference with no DST drift. Positive means overdue. Null means no due date,* which is not the same as due today.*/const daysPastDue = (dueDateLocal: string | null, now: Date = new Date()): number | null => {if (!dueDateLocal) return nullconst today = localDay(now, MERIDIAN_TZ)return Math.round((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${dueDateLocal}T00:00:00Z`)) / 86_400_000,)}/*** One Ninety to-do as a row on My Day.** The age string is deliberately not the same shape as an order's. An order line* shows "34d" meaning days waiting; a to-do shows "9d over" or "due in 5d". They* are different measurements and one column showing both as a bare number would* invite a reader to compare them. `parseInt` still sorts it: "due in 5d" and* "–" both fall to 0, so nothing not yet due outranks something actually late.*/export function todoItem(t: Todo, now: Date = new Date()): WorkItem {const gates = reviewGatesMissed(t.dueDate, now)const late = isActuallyLate(t.dueDate, now)const over = daysPastDue(t.dueDateLocal, now)const meta = ['Ninety to-do',// dueDateLocal, never dueDate. The raw instant is correct for comparisons// and wrong to slice for display. See types.ts on Todo.dueDate.t.dueDateLocal ? `due ${t.dueDateLocal}` : 'no due date',// Why the same title can appear twice: recurrence is not collapsed here, on// purpose. See myDay().t.repeat,gates > 0 ? `past ${gates} review gate${gates === 1 ? '' : 's'}` : null,].filter(Boolean)return {what: t.title,meta: meta.join(' · '),age: over === null ? '–' : over > 0 ? `${over}d over` : `due in ${-over}d`,color: late ? C.magenta : C.blueDeep,late,kind: 'todo',}}/*** The participant gate: which meetings this viewer may be shown anything about.** This function is the security boundary for meeting content. Fathom's own* permissions cannot help: every meeting on this account reads* `shared_with: no_teams`, so one key sees everything and the filtering is ours* to get right (config/fathom-keys.ts records why).** Three return states, and they are not interchangeable:* null: not tracked. No key configured, or the fetch failed. Never an* empty list, because "no meetings" and "we did not look" lead to* opposite conclusions and the prime invariant forbids conflating them.* []: this viewer was in none of the meetings we did fetch.* [...]: meetings this viewer attended.** Fails closed in every ambiguous case: the company variant (no roster person),* an unidentified viewer, and a meeting whose participant list does not name* them all yield nothing. A person appearing in `unmatchedParticipants` is not* a viewer: the board could not resolve them, and resolving-by-guess is how a* leadership meeting reaches the wrong desk.*/export function meetingsFor(snap: Snapshot, viewer: Viewer | null): MeetingRecord[] | null {if (!snap.fathomConfigured || !snap.meetings.ok) return nullconst name = viewer?.person?.nameif (!name) return []return snap.meetings.data.filter((m) => m.participants.includes(name))}/*** One person's card: what is mine today, and what I promised.** Items come from every source the header counts (order lines, Bitrix tasks,* Ninety to-dos, tracker rows), so the number above the list and the list can* account for each other (rule 9). Several people hold no order lines at all,* and a card built from orders alone would read as a number above an empty* list.** The viewer is an argument because to-do titles attach only to the card* belonging to the viewer. A person's own to-do is not confidential from them* (Ninety shows it to them), so their own list is complete whatever team the* item carries. What `config/ninety-teams.ts` protects is other people's* Leadership items, and this surface never renders those: anything withheld is* counted in `todosWithheld` so the number above the list still adds up.** Defaults to COMPANY_VIEWER, which owns nothing, for the reason given on* render(): getting less than you should is a bug report, getting more is an* incident.*/export function myDay(snap: Snapshot, view: PersonView, viewer: Viewer = COMPANY_VIEWER): MyDay {const lines = snap.orderLines.data.filter((l) => l.productionOwner === view.name || l.accountOwner === view.name,)const cls = (l: OrderLine) => classifyOrder(l.orderStatus, l.orderStatusId || null)const ours = lines.filter((l) => cls(l) === 'ours')const waiting = lines.filter((l) => cls(l) === 'client')const { real } = partitionTasks(snap.tasks.data)// By id, not by name; see personFromBitrix(). A name lookup on a numeric id// matches nobody.const mine = real.filter((t) => personFromBitrix(t.responsibleId)?.name === view.name)// Roster-resolved, not an equality join on the Ninety spelling. Ninety says// "Samuel Whitaker" where the roster says "Sam Whitaker", and matching on the// string drops people to zero without saying so (see todoLoad in// collect.ts). Every ninetyId is pinned, so this is an id match with a name// fallback, not a guess.//// Recurrence is not collapsed. `todoLoad()` counts every series instance, so// the header includes both; collapsing only the list would put the two back out// of agreement. `todoItem` surfaces `repeat` so a repeated title explains// itself.const isMine = viewer.ownBriefing === view.nameconst myTodos = isMine? snap.ninety.data.filter((t) => personFromNinety(t.owner.id, t.owner.name)?.name === view.name,): []const todoItems: WorkItem[] = myTodos.map((t) => todoItem(t))const orderItems: WorkItem[] = ours.map((l) => {// Plinth often carries the order number in both fields. "MD2639585 · MD2639585"// is worse than either half on its own.const who = l.company ?? l.customer ?? nullconst product = l.productTitle || nullconst what = [who, product && product !== who ? product : null].filter(Boolean).join(' · ')return {what: what || `Order ${l.orderId}`,meta: `Order ${l.orderId} · ${l.productStatus} · Plinth`,age: ageOf(l.ageDays),color: (l.ageDays ?? 0) >= 20 ? C.magenta : C.blue,late: false,kind: 'order',id: `MD26${l.orderId}`,noteKey: `order:MD26${l.orderId}`,}})const taskItems: WorkItem[] = mine.map((t) => {const gates = reviewGatesMissed(t.deadline)return {what: t.title,meta: gates > 0 ? `Bitrix task · past ${gates} review gate${gates === 1 ? '' : 's'}` : 'Bitrix task',age: ageOf(t.ageDays),color: C.orange,late: gates > 0,kind: 'task',noteKey: `task:${t.id}`,}})// Tracker rows for this person. Shared rows appear on every named owner's// card: one commitment, but each of them has to show up for it.const myTracker = isMine? snap.sheet.data.filter((t) =>classifyTrackerStatus(t.status) === 'ours' &&t.owners.some((o) => personFromTrackerOwner(o)?.name === view.name),): []const trackerItems: WorkItem[] = myTracker.map((t) => ({what: t.title,meta: [`Tracker · ${t.tab}`,t.sku,t.status || 'no status',// Shown as typed. The sheet mixes 08/19/26 and 08/19/2026 and 8/21/2026,// and inferring a year or a locale from that is how you call something// overdue that is not.t.deadline ? `due ${t.deadline}` : null,t.shared ? `with ${t.owners.filter((o) => personFromTrackerOwner(o)?.name !== view.name).join(', ')}` : null,].filter(Boolean).join(' · '),// No age. The tracker has no created date and its deadlines are free text,// so there is no number here that would mean the same thing as an order's// "34d". An invented one would sort against real ages and win.age: '–',color: C.yellow,late: false,kind: 'tracker' as const,}))// Meeting commitments, participant-gated://// - You see meetings you were in, and nothing about the ones you were not.// A leadership meeting must not reveal its task list, or its existence, to// people who were deliberately not in it.// - Inside a meeting you attended you see all of its items, not just yours.// You were in the room; you already heard them. What that buys is a record// of what the meeting produced and who owns each piece.// - Unowned items (nobody named) go to every participant, because one of// the people in the room owns it and they are the only ones who can say// which.//// A consequence worth knowing: an item assigned to you in a meeting you did// not attend does not appear here. It is not lost: everyone who was in that// room sees it with your name on it, so the routing is human. Surfacing it// here would leak the meeting.const cutoff = Date.now() - COMMITMENT_WINDOW_DAYS * 86_400_000const attended = meetingsFor(snap, viewer)const commitments =attended === null? []: attended.filter((m) => Date.parse(m.date) >= cutoff).flatMap((m) =>m.items.map((i) => ({text: i.text,meeting: m.title,date: m.date,url: i.playbackUrl ?? m.url,owner: i.owner,externalOwner: i.externalOwner,unknownOwner: i.unknownOwner,/** True when no name attached at all: the ones most easily missed. */unowned: !i.owner && !i.externalOwner && !i.unknownOwner,})),)/** Null when not tracked, so the card can say so rather than showing zero. */const commitmentsTracked = attended !== null// A dark key is stated on the card itself, not only in the source band at the// bottom of the page. Somebody scanning their own commitments never scrolls// that far, and a list short by one key looks exactly like a light week.const darkKeys = snap.fathomSources.filter((k) => !k.ok)const commitmentsPartial =commitmentsTracked && darkKeys.length? `The Fathom ${plural(darkKeys.length, 'key')} for ` +`${darkKeys.map((k) => k.owner).join(' and ')} could not be read this run, so meetings ` +`they recorded are missing from this list rather than absent from the fortnight.`: nullconst items = [...orderItems, ...taskItems, ...todoItems, ...trackerItems].sort((a, b) => (parseInt(b.age, 10) || 0) - (parseInt(a.age, 10) || 0),)// Named, not counted, so the empty state can say which source is dark. A list// that is short because Ninety 403'd looks exactly like a clear desk.const sourcesDown: string[] = []if (!snap.orderLines.ok) sourcesDown.push('Plinth orders')if (!snap.tasks.ok) sourcesDown.push('Bitrix tasks')if (!snap.ninety.ok) sourcesDown.push('Ninety to-dos')if (!snap.sheet.ok) sourcesDown.push('the Content Creation tracker')return {person: view,ourMove: ours.length,waiting: waiting.length,tasks: mine.length,todos: todoItems.length,tracker: trackerItems.length,// What the header counts but this card does not list. Zero on the viewer's// own card, which is the only card that is rendered today.todosWithheld: isMine ? 0 : (view.todos ?? 0),sourcesDown,items,commitments,commitmentsTracked,commitmentsPartial,// To-dos included. This drives the footer sentence about review gates,// which must not say "nothing is past a review gate" above an overdue to-do.lateCount: items.filter((i) => i.late).length,}}// --- what moved elsewhere -----------------------------------------------------export interface FeedItem {dept: stringtone: 'primary' | 'accent' | 'dark' | 'hot'time: stringtext: stringwhy: string/** Sort key. Null when the source records no timestamp at all. */at: number | null}const clock = (iso: string): string =>new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, hour: 'numeric', minute: '2-digit' }).format(new Date(iso),)/*** The cross-department feed Growth and Client Success never had.** Stated on the surface itself: neither Plinth nor Bitrix keeps an event log* this can read. Everything below is inferred from timestamps that do exist (`updatedAt` on a lead, `placedAt` on an order), plus the standing* facts that are worth knowing regardless of when they changed. So it is an* honest "what looks new", not a changelog, and it must never be described as* one. When a record carries no timestamp it is listed with the time omitted* rather than with a plausible-looking one invented for it.*/export function feed(snap: Snapshot, hours = 24): FeedItem[] {const since = Date.now() - hours * 3_600_000const out: FeedItem[] = []for (const l of snap.leads.data) {if (l.noise || !l.updatedAt) continueconst at = Date.parse(l.updatedAt)if (!Number.isFinite(at) || at < since) continueconst stage = stageName(l.statusId)const isReply = stage === 'Client Replied'out.push({dept: 'CS',tone: isReply ? 'accent' : 'primary',time: clock(l.updatedAt),text: `${l.company ?? l.title} moved to ${stage}`,why: isReply? 'They are waiting on us. This is the one to answer first': 'Lead pipeline movement Growth would otherwise hear about late',at,})}for (const l of snap.orderLines.data) {if (!l.placedAt) continueconst at = Date.parse(l.placedAt)if (!Number.isFinite(at) || at < since) continueout.push({dept: 'Ops',tone: 'dark',time: clock(l.placedAt),text: `New order ${l.orderId}: ${l.company ?? l.customer ?? 'customer'}`,why: `${l.productTitle || 'order line'} · now in ${l.productStatus}`,at,})}const exceptions = snap.orderLines.data.filter((l) => EXCEPTION_STATUSES.test(l.orderStatus))const unowned = exceptions.filter((l) => !l.productionOwner && !l.accountOwner)if (unowned.length) {const value = unowned.reduce((s, l) => s + (l.amount || 0), 0)out.push({dept: 'Ops',tone: 'dark',time: '–',text: `${unowned.length} exception${unowned.length === 1 ? '' : 's'} logged with no owner`,why: value ? `$${value.toLocaleString('en-US')} not captured` : 'Each needs a decision, not a task',at: null,})}// Assignments arrive on the API, so the failure worth reporting is the// fields not arriving, not staleness.const assignCov = snap.orderWindow.assignmentFieldsif (snap.mode === 'live' &&snap.orderLines.ok &&assignCov.total > 0 &&assignCov.production === 0 &&assignCov.accountOwner === 0) {out.push({dept: 'Ops',tone: 'hot',time: '–',text: 'Plinth stopped returning the assignment fields',why: 'Per-person order counts are blind: missing, not zero',at: null,})}// Timestamped items first, newest to oldest; standing facts settle underneath.return out.sort((a, b) => (b.at ?? -1) - (a.at ?? -1)).slice(0, 14)}// --- coverage matrix ----------------------------------------------------------export interface CoverageCell {text: stringcolor: string}export interface CoverageRow {name: stringcells: CoverageCell[]}export const COVERAGE_COLUMNS = ['Plinth design','Plinth client success','Bitrix tasks','Ninety to-dos',] as const/*** Ninety is the one source on this board that covers everyone.** Plinth only sees people holding order lines, and Bitrix tasks only sees* people with tasks, so anyone with neither is structurally invisible to them.* `GET /todos/company` is company-scoped by design, so when it is up, every* person is genuinely covered by it.** Note what "covered" means in this table: the source can see this person, not* that the person has work. Somebody with zero open to-dos is covered and idle,* which is a different and much less alarming thing than untracked.*/export function coverage(snap: Snapshot): CoverageRow[] {// No category hues here: 'not tracked' in orange would read as something// about Bitrix tasks, and 'source down' in magenta as something about clients. The// cell kind drives a texture in the surface instead.const covered: CoverageCell = { text: 'covered', color: C.ink }const na: CoverageCell = { text: '–', color: C.gray }const gap: CoverageCell = { text: 'not tracked', color: C.ink }const down: CoverageCell = { text: 'source down', color: C.ink }return MERIDIAN_PEOPLE.map((p) => {const hasPlinth = p.plinthAccounts.length > 0const design = p.coveredBy.includes('plinth-designer')const cs = p.coveredBy.includes('plinth-client-success')return {name: p.name,cells: [design ? covered : hasPlinth ? na : gap,cs ? covered : hasPlinth ? na : gap,p.coveredBy.includes('bitrix-tasks') ? covered : gap,snap.ninety.ok ? covered : down,],}})}// --- briefing -----------------------------------------------------------------export interface BriefingDelta {label: stringvalue: numberdelta: Delta | nullcolor: string/** False when the source behind the number did not answer. Render a dash. */available: boolean}export interface Briefing {/** America/Chicago, e.g. "Wednesday, August 12". */dateLong: stringdateShort: stringgeneratedAt: stringliveSources: numbertotalSources: numberheadlineDeltas: BriefingDelta[]answerFirst: DrillRow[]loadedSentences: string[]blindSpots: string[]/** Null when there is no earlier run to compare with. */comparedTo: string | null}const fmtLong = (d: Date) =>new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, weekday: 'long', month: 'long', day: 'numeric' }).format(d)const fmtShort = (d: Date) =>new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, weekday: 'short', month: 'short', day: 'numeric' }).format(d)/*** The prose briefing's sentences, built from the same numbers as the board.** Every clause here is derived, never templated over an assumption. In* particular the workload sentence says "past a review gate" and not "days* overdue", because at Meridian nothing closes outside a Monday review. A briefing* that says "Sam is 15 days overdue" about finished work says it in front of* the whole team.*/export function briefing(snap: Snapshot,h: Headline,people: PersonView[],board: PeopleBoard,history: HistoryRow[],today: string,): Briefing {const now = new Date(snap.syncedAt)const prev = previousRun(history, today)const sources = sourceStatuses(snap)const d = drills(snap)const ordersOk = snap.orderLines.okconst bothOk = ordersOk && snap.leads.okconst headlineDeltas: BriefingDelta[] = [{label: 'Our move',value: h.ourMove,delta: bothOk ? delta(h.ourMove, prev, 'ourMove') : null,color: C.ink,available: bothOk,},{label: 'Design queue',value: h.designQueue,delta: ordersOk ? delta(h.designQueue, prev, 'designQueue') : null,color: C.blue,available: ordersOk,},]const tracked = people.filter((p) => p.load !== null)const heaviest = [...tracked].sort((a, b) => (b.load ?? 0) - (a.load ?? 0))const untracked = people.filter((p) => p.plinthOurMove === null)const loadedSentences: string[] = []const top = heaviest[0]if (top) {const oldest = top.oldestDays !== null ? ` and the oldest has been with them ${top.oldestDays} days` : ''loadedSentences.push(`${top.name} is carrying ${top.load} open item${top.load === 1 ? '' : 's'}${oldest}. That is the heaviest board on the team.`,)}for (const p of heaviest.slice(1, 4)) {const bits: string[] = [`${p.name} is at ${p.load}`]if (p.actuallyLate) {bits.push(`${p.actuallyLate} of them past a Monday review gate, which is what late means here`)}if (p.plinthWaiting) {bits.push(`${p.plinthWaiting} more ${p.plinthWaiting === 1 ? 'sits' : 'sit'} with the client and ${p.plinthWaiting === 1 ? 'is' : 'are'} not on their board today`,)}loadedSentences.push(`${bits.join(', ')}.`)}if (untracked.length) {loadedSentences.push(`${untracked.map((p) => p.short).join(' and ')} hold no Plinth assignment, so whatever they are doing is invisible to this. Do not read their small numbers as spare capacity.`,)}const blindSpots: string[] = []for (const s of sources) {if (s.tone === 'failed') blindSpots.push(`${s.name} failed this morning, so its numbers are missing rather than zero.`)if (s.tone === 'absent') blindSpots.push(`${s.name} is ${s.status}, so nothing it covers is in this.`)// "Name: status." rather than a sentence built around the status: statuses// are sometimes phrases ("92% of lines") and sometimes single words// ("partial"), and a sentence that reads like a typo gets skimmed past,// which a blind-spot line cannot afford.if (s.tone === 'degraded') blindSpots.push(`${s.name}: ${s.status}. Treat those figures as indicative.`)}return {dateLong: fmtLong(now),dateShort: fmtShort(now),generatedAt: new Intl.DateTimeFormat('en-US', {timeZone: MERIDIAN_TZ,hour: 'numeric',minute: '2-digit',}).format(now),liveSources: sources.filter((s) => s.tone === 'live').length,totalSources: sources.length,headlineDeltas,// Client-replied first, then unowned exceptions: both are somebody waiting.answerFirst: [...(d.replied?.rows ?? []), ...(d.exceptions?.rows ?? [])].slice(0, 6),loadedSentences,blindSpots,comparedTo: prev?.day ?? null,}}// --- shared chart rows ---------------------------------------------------------export interface StageBar extends StageCount {pct: stringcolor: string}export function stageBars(rows: StageCount[]): StageBar[] {const max = Math.max(1, ...rows.map((r) => r.count))return rows.map((r) => ({...r,pct: `${Math.round((r.count / max) * 100)}%`,color: CLASS_COLOR[r.class] ?? C.gray,}))}export interface LeadRow {age: numberageColor: stringstage: stringtitle: stringcompany: string | null}/*** Parked leads are excluded on purpose: they are paused deliberately, so they* are not aging in any sense that should worry anyone, and including them buries* the ones that are.*/export function oldestLeads(leads: Lead[], limit = 10): LeadRow[] {return agedLeads(leads)// Terminal is not open. A completed lead listed under "Oldest open leads"// is a finished job presented as the oldest thing rotting in the pipeline,// so both parked and terminal leads are excluded..filter((l) => {const c = classifyLead(l.statusId, l.semantic)return c !== 'parked' && c !== 'terminal'}).slice(0, limit).map((l) => ({age: l.ageDays,ageColor: l.ageDays >= 60 ? C.magenta : C.ink,stage: stageName(l.statusId),title: l.title,company: l.company,}))}// --- the queue: order numbers, and who is holding them ----------------------/*** The panel people asked for by name.** A headline number tells leadership the shape of the day. It tells a designer* nothing they can act on. This is the other thing: a pasteable order number, the product, how* long it has sat, and whose desk it is on.** Every string here is pre-formatted for display, including the honest absences.* "not recorded" and "unassigned" are values, not empty cells. A blank in a* designer column reads as "no designer needed", which is the opposite of what* an unassigned order means.*/export interface QueueRowView {orderNumber: stringproduct: stringstatus: stringstatusColor: stringcustomer: stringage: stringageDays: number | null/** True when this row is genuinely old and should be visually weighted. */aging: booleandesignQueue: boolean}export interface QueueGroupView {/** The designer's name, or "Unassigned". Never blank. */designer: stringunassigned: booleancount: number/** "3 orders · oldest 46 days" */summary: stringrows: QueueRowView[]}export interface QueueView {available: boolean/** Why it is empty or partial, written for a person. */reason: stringtotal: numbergroups: QueueGroupView[]/** Orders nobody is assigned to. A data gap, called that, not a queue. */unassignedCount: number/*** Set when almost everything is old, in which case the per-row badges are* suppressed and this sentence replaces them. Null when aging is genuinely* the exception and the badges carry information.*/everythingIsOld: string | null}/** Anything sitting longer than this gets visual weight in the queue. */const AGING_DAYS = 14/*** Above this share, the flag stops being an exception and becomes the weather.** A badge on 13 of 13 rows is not information; it is a background colour that* teaches people to stop seeing the word. When nearly everything trips the* threshold, the honest move is to say it once, in a sentence, and drop the* badges entirely.*/const FLAG_IS_MEANINGLESS_ABOVE = 0.6function queueRowView(r: OrderQueueRow, showAgingFlags: boolean): QueueRowView {const text = (v: string | null, absent: string) => (v && v.trim() ? v : absent)return {orderNumber: r.orderNumber,// A null product is the export not carrying one. Say that rather than// echoing the order number back into the column beside itself.product: text(r.product, 'product not in export'),status: r.status,statusColor: CLASS_COLOR[classifyOrder(r.status) as keyof typeof CLASS_COLOR] ?? C.gray,// Same trap as product: an order-level export has no customer name, and// falling back to the order number would make three columns in a row say// the same thing.customer: text(r.company ?? r.customer, 'client not in export'),age: r.ageDays === null ? 'age unknown' : `${r.ageDays}d`,/** Raw days for the 60-day age track; null renders no track at all. */ageDays: r.ageDays,aging: showAgingFlags && (r.ageDays ?? 0) >= AGING_DAYS,designQueue: r.designQueue,}}export function queueView(snap: Snapshot): QueueView {if (!snap.orderLines.ok) {return {available: false,reason: `Plinth is not answering, so there is no queue to show. This is not an empty queue: ${snap.orderLines.error}`,total: 0,groups: [],unassignedCount: 0,everythingIsOld: null,}}const groups = orderQueueByDesigner(snap.orderLines.data)const total = groups.reduce((n, g) => n + g.rows.length, 0)const unassignedCount = groups.find((g) => g.designer === null)?.rows.length ?? 0const allRows = groups.flatMap((g) => g.rows)const old = allRows.filter((r) => (r.ageDays ?? 0) >= AGING_DAYS)const saturated = total > 0 && old.length / total > FLAG_IS_MEANINGLESS_ABOVEconst ages = allRows.map((r) => r.ageDays).filter((d): d is number => d !== null)const median = ages.length ? [...ages].sort((a, b) => a - b)[Math.floor(ages.length / 2)]! : nullreturn {available: true,reason: unassignedCount? snap.orderWindow.assignmentFields.production === 0 &&snap.orderWindow.assignmentFields.total > 0? `${unassignedCount} of ${total} order lines carry no designer, but Plinth returned no production_owner field at all this run, so that is a coverage gap, not an empty desk.`: `${unassignedCount} of ${total} order lines are unassigned in Plinth. Nobody is holding them.`: `${total} order lines, all assigned.`,total,unassignedCount,// Said once instead of stamped on every row. A badge that fires on// everything stops being read by the second week.everythingIsOld: saturated? `${old.length} of ${total} lines are over ${AGING_DAYS} days old` +(median === null ? '' : `, median ${median} days`) +`. Age is not the exception here, so the per-row markers are turned off; ` +`sort by the Age column instead.`: null,groups: groups.map((g) => {const ages = g.rows.map((r) => r.ageDays).filter((d): d is number => d !== null)const oldest = ages.length ? Math.max(...ages) : nullreturn {designer: g.designer ?? 'Unassigned',unassigned: g.designer === null,count: g.rows.length,summary:`${g.rows.length} ${plural(g.rows.length, 'order line')}` +(oldest === null ? '' : ` · oldest ${oldest} ${plural(oldest, 'day')}`),rows: g.rows.map((r) => queueRowView(r, !saturated)),}}),}}export interface ViewModel {snap: Snapshotheadline: Headlineboard: PeopleBoardpeople: PersonView[]peopleAll: PersonView[]drills: Record<string, Drill>sources: SourceStatus[]orderStages: StageBar[]leads: LeadRow[]feed: FeedItem[]coverage: CoverageRow[]briefing: BriefingmyDays: MyDay[]queue: QueueViewcaveats: string[]caveatRows: { src: string; text: string }[]failures: string[]}export function buildViewModel(snap: Snapshot,headline: Headline,history: HistoryRow[],today: string,/*** Who this build is for. Only My Day reads it, and only to decide whose to-do* titles it may list. Defaults to the viewer who owns nothing, so a caller that* forgets the argument discloses nothing.*/viewer: Viewer = COMPANY_VIEWER,): ViewModel {const board = peopleBoard(snap)const people = personViews(board, snap.tasks.data, 'ours')const peopleAll = personViews(board, snap.tasks.data, 'all')const failures: string[] = []if (!snap.leads.ok) failures.push(`Bitrix leads failed: ${snap.leads.error}`)if (!snap.orderLines.ok) failures.push(`Plinth orders failed: ${snap.orderLines.error}`)if (!snap.tasks.ok) failures.push(`Bitrix tasks failed: ${snap.tasks.error}`)if (!snap.sheet.ok) {failures.push(`Content Creation tracker failed: ${snap.sheet.error}. A 401 or 403 means the ` +`sheet's link sharing was tightened. That public read is how this source ` +`works at all.`,)}// No appended guess (rule 1). The client's errors already say what// happened, and the 403 carries its endpoint-specific explanation itself;// a fixed sentence appended here would put a 403's explanation on a 429.if (!snap.ninety.ok) failures.push(`Ninety to-dos failed: ${snap.ninety.error}`)const caveats: string[] = [...snap.assignmentWarnings]// A truncated sweep is a caveat on every order number on this page, so it// goes first. Plinth pages by product line and reports no total, so nothing// in the rows themselves reveals that the window was cut short.if (snap.orderWindow.truncated) {caveats.push(`The Plinth sweep hit its page cap, so every order count here is a FLOOR rather ` +`than a total: there are more orders in the window than this page can see. ` +`Narrow the window or raise the page cap before acting on an order number.`,)}// Same for leads: on the page, not only in a terminal.if (snap.leadWindow.truncated) {caveats.push(`The Bitrix lead sweep hit its page cap, so the open-lead count is a FLOOR ` +`rather than a total.`,)}if (headline.unknownStatuses.length) {caveats.push(`${headline.unknownStatuses.length} product status${headline.unknownStatuses.length === 1 ? ' is' : 'es are'} unclassified and counted as our move: ${headline.unknownStatuses.join(', ')}.`,)}// A partial sheet read is a caveat on every tracker number on this page. The// per-tab loop reports what it got rather than throwing, which is right: three// tabs of four is most of the picture. But "most" has to be said out loud, or// the counts read as complete.if (snap.sheetTabs.failed.length) {caveats.push(`The Content Creation tracker was read only in part: ` +`${snap.sheetTabs.failed.map((f) => `${f.label} (${f.reason})`).join('; ')}. ` +`Every tracker count on this page is a FLOOR, not a total.`,)}const tc = trackerCounts(snap)if (snap.sheet.ok && tc.unclassified) {caveats.push(`${tc.unclassified} Content Creation tracker row${tc.unclassified === 1 ? '' : 's'} ` +`${tc.unclassified === 1 ? 'has' : 'have'} a Status of ${tc.unknownStatuses.join(', ')}, ` +`which nothing classifies, counted as neither open nor done rather than assumed either way.`,)}if (snap.sheet.ok && tc.unmappedOwners.length) {caveats.push(`Content Creation tracker owner${tc.unmappedOwners.length === 1 ? '' : 's'} ` +`${tc.unmappedOwners.join(', ')} match nobody on the roster, so those rows appear on no one's board.`,)}caveats.push(snap.fathomConfigured? 'Meeting commitments show only the meetings you were in. Someone else\u2019s page shows theirs, and neither page lists the other\u2019s.': 'Fathom is not configured, so meeting commitments read as not tracked rather than zero.',)/*** The caveats again, labelled by source for the redesigned band: one row per* note with the source named in the left column, instead of one paragraph* joined by dots. Same strings, so nothing asserted on vm.caveats moves.*/const labelFor = (text: string): string => {if (/Plinth sweep/i.test(text)) return 'Plinth orders'if (/lead sweep/i.test(text)) return 'Bitrix leads'if (/tracker/i.test(text)) return 'Content tracker'if (/Fathom/i.test(text)) return 'Fathom'if (/product status/i.test(text)) return 'Plinth statuses'if (/[Aa]ssignment/.test(text)) return 'Plinth assignments'return 'Note'}const caveatRows = caveats.map((text) => ({ src: labelFor(text), text }))return {snap,headline,board,people,peopleAll,drills: drills(snap),sources: sourceStatuses(snap),orderStages: stageBars(productStages(snap.orderLines.data)),leads: oldestLeads(snap.leads.data),feed: feed(snap),coverage: coverage(snap),briefing: briefing(snap, headline, people, board, history, today),myDays: people.map((p) => myDay(snap, p, viewer)),queue: queueView(snap),caveats,caveatRows,failures,}}