history.ts
Language TypeScript4.9 KB118 lines
/*** Day-over-day deltas.** The briefing mockup shows "▲ 5 vs yesterday" next to the artwork-review count,* and that arrow is the most useful thing on the page: a queue of 40 means* nothing, a queue of 40 that grew by 5 overnight means the designers are losing* ground. But nothing in Plinth or Bitrix records history, so the only way to have* a yesterday is to keep one.** This writes one small row per run to `data/dashboard-history.json` and reads* the most recent row from a previous local day back out.** The rule this file exists to enforce: no prior run means no delta. Not a zero,* not a dash that reads like "unchanged". The arrow is simply absent and the* page says the comparison is not available yet. An invented "▼ 0" on the first* morning would be the dashboard's first lie, and the second one is much easier.** Runs are keyed by America/Chicago day, not by UTC, because the 6:00 AM CT sync* is what people are comparing. Several runs on the same day overwrite each* other rather than accumulating, so re-running the build at 9:00 does not make* "yesterday" mean "an hour ago".*/import { localDay } from '@meridian/ninety-client'import type { Headline } from './collect.js'// File I/O lives in history-file.ts, not here. `rowFrom`, `previousRun` and// `delta` are pure and are needed by the Cloudflare Worker, which stores history// in KV and has no filesystem. A static `node:fs` import in this module would// break the Worker build even though it never calls any of it (rule 7)./*** Bump this whenever the meaning of a recorded number changes (rule 6).** Comparing rows across a definition change announces a swing that never* happened, the morning after a code change nobody outside this repo knew* about. A delta is only meaningful between two rows that counted the same thing.* Rows with a different `defs` are ignored by previousRun(), so the comparison* simply restarts and says so, which is the same rule as the first morning.** 1 original* 2 openLeads excludes terminal stages (Junk, Reorder Completed, Converted* Lead, Out of Scope, Unconverted Lead); ourMove excludes them too* 3 the Plinth sweep pages through its whole window rather than stopping* after the oldest page; every order-derived number (ourMove,* designQueue, waitingOnClient, atVendor, needsReply) grows* 4 order lines are classified on the product status, gated on the order* not having shipped past Part Dispatched (see classifyLine); every* order-derived number moves, including the per-person workloads and the* queue*/export const DEFS_VERSION = 4/** Kept deliberately small: only the numbers a delta is ever shown for. */export interface HistoryRow {/** America/Chicago calendar day, YYYY-MM-DD. */day: stringat: string/** Definition version these numbers were computed under. See DEFS_VERSION. */defs?: numberourMove: numberdesignQueue: numberwaitingOnClient: numberatVendor: numberneedsReply: numberopenLeads: number}export interface Delta {value: number/** 'up' | 'down' | 'flat', from the reader's point of view, not the number's. */direction: 'up' | 'down' | 'flat'/** The day being compared against, for the tooltip. */since: string}/** How many rows to keep. Enough for a month of weekdays, small enough to read. */export const KEEP = 40export function rowFrom(h: Headline, at: Date = new Date()): HistoryRow {return {day: localDay(at),at: at.toISOString(),defs: DEFS_VERSION,ourMove: h.ourMove,designQueue: h.designQueue,waitingOnClient: h.waitingOnClient,atVendor: h.atVendor,needsReply: h.needsReply,openLeads: h.realLeads,}}/*** The most recent row from a day before `today`. Returns null when there is no* previous day on file, which is the honest answer on the first morning and* after any gap, and which every caller must render as "no comparison" rather* than as zero.*/export function previousRun(history: HistoryRow[], today: string): HistoryRow | null {// Rows recorded under an older definition are skipped, not compared. A delta// across a definition change is a fabricated swing (see DEFS_VERSION).const earlier = history.filter((r) => r.day < today && (r.defs ?? 1) === DEFS_VERSION)return earlier.length ? earlier[earlier.length - 1]! : null}/*** `up` and `down` describe the number, not whether it is good news. Which* direction is good depends on the metric, so the caller decides the color:* our-move falling is good, at-vendor falling may just mean nothing shipped.*/export function delta(now: number, prev: HistoryRow | null, key: keyof HistoryRow): Delta | null {if (!prev) return nullconst was = prev[key]if (typeof was !== 'number') return nullconst d = now - wasreturn { value: d, direction: d > 0 ? 'up' : d < 0 ? 'down' : 'flat', since: prev.day }}