fixtures.ts
Language TypeScript19.7 KB454 lines
/*** Sample data shaped like the real thing, so `pnpm dashboard --fixtures` renders* without credentials.** Everything here is invented: counts, stage mix, customer names, lead and task* titles. This exists so layout and classification bugs are found before they* reach a live page, and so the page can be shown to someone who has no* Plinth access.*/import { isJunkTask, type Lead, type Task } from '@meridian/bitrix-client'import type { OrderLine } from '@meridian/plinth-client'import { assignmentSpread, localDay, MERIDIAN_TZ, type Todo } from '@meridian/ninety-client'import type { TrackerTask } from '@meridian/sheets-client'import type { MeetingRecord, Snapshot } from './collect.js'import { PLINTH_SEED, SEED_EXPORTED_AT } from './plinth-seed.js'import { FATHOM_SEED } from './fathom-seed.js'import { attributeFathomAssignee } from '../../../config/fathom-people.js'import { displayName } from '../../../config/plinth-people.js'/*** `movedHoursAgo` drives the Elsewhere feed, which reads `updatedAt`. With it* null on every fixture lead, that surface would only ever render its empty* state. A few leads move in the last day; the rest do not.*/const lead = (id: string,title: string,statusId: string,daysAgo: number,company?: string,movedHoursAgo?: number,): Lead => ({id,title,company: company ?? null,statusId,createdAt: new Date(Date.now() - daysAgo * 86_400_000).toISOString(),updatedAt:movedHoursAgo === undefined ? null : new Date(Date.now() - movedHoursAgo * 3_600_000).toISOString(),assignedById: '1',amount: 0,noise: false,noiseReason: null,// Fixture leads are all in configured process stages, so Bitrix's own// grouping is 'P'. Real data also carries 'S'/'F' on terminal stages.semantic: 'P' as const,})const noiseLead = (id: string, title: string): Lead => ({...lead(id, title, 'UC_VOHNVF', 1),noise: true,noiseReason: 'automated',})/*** Bitrix user ids, as user.get returns them. Roster people plus the accounts* worth reproducing.** Numbers, because Bitrix sends a numeric user id and nothing else. A fixture* that passed display names would reproduce the shape a name-matching bug* expects rather than the shape the API sends, and a fixture that agrees with* a bug cannot catch it. Fixtures are the contract: real structure, invented* values.*/const BITRIX = {sam: '1924',hazel: '2736',marco: '7108',ines: '7404',theo: '1698',nora: '1',/** A former operations account, inactive. Holds a series of dead FedEx rows. */departed: '26',/** "Meeting Room": a resource, not a person. Holds the `test` rows. */meetingRoom: '196',/** A former marketing account, inactive, that still holds open tasks. */offRoster: '4',} as constconst task = (id: string,title: string,responsible: string | null,deadline: string | null,): Task => ({id,title,responsibleId: responsible,deadline,createdAt: new Date(Date.now() - 20 * 86_400_000).toISOString(),ageDays: 20,overdue: deadline ? Date.parse(deadline) < Date.now() : false,...isJunkTask(title),junkReason: isJunkTask(title).reason,})function repeat<T>(n: number, fn: (i: number) => T): T[] {return Array.from({ length: n }, (_, i) => fn(i))}const LEADS: Lead[] = [...repeat(23, (i) => lead(`r${i}`, `Reorder: client ${i + 1}`, '7', 3 + i, 'Sample Co')),...repeat(20, (i) => lead(`n${i}`, `No reply: prospect ${i + 1}`, '2', 20 + i * 2)),...repeat(8, (i) => lead(`q${i}`, `Quote sent: prospect ${i + 1}`, 'UC_E1JC2S', 5 + i)),...repeat(5, (i) => lead(`w${i}`, `Waiting on client ${i + 1}`, '9', 9 + i)),...repeat(4, (i) => lead(`p${i}`, `Parked ${i + 1}`, '8', 40 + i)),...repeat(3, (i) => lead(`u${i}`, `New inquiry ${i + 1}`, 'UC_VOHNVF', i, undefined, 5 + i * 4)),lead('c1', 'Client replied: needs an answer', 'UC_Y4AE5V', 2, 'Acme Events', 3),...repeat(2, (i) => lead(`a${i}`, `Quote approved ${i + 1}`, 'IN_PROCESS', 6 + i, 'Sample Co', 9 + i * 6)),lead('f1', 'First reply', 'PROCESSED', 1),lead('s1', 'Consultation scheduled', 'UC_BB3F0W', 2),lead('as1', 'Allocated', 'NEW', 1, 'Cedar Hollow HOA', 11),noiseLead('x1', 'Security alert: new passkey added to your Claude account'),noiseLead('x2', '1 555-010-0199 - Incoming call'),noiseLead('x3', 'Complete CRM form "Website Chat"'),]/*** What the product row reads when the order reads X. Taken from the shape of* live Plinth data: the two catalogs share some names and not others, and* `Proof Out` is one of the ones that exists only at order level: an* order waiting on client approval carries a product row of "Awaiting Proof or* Design Approval", which is the product catalog's name for the same wait.*/const PRODUCT_FOR_ORDER: Record<string, string> = {'Proof Out': 'Proof With Client', // class 'client': waiting on them'File Check': 'File Check','Proof Signed Off': 'Proof Signed Off','Design Requested': 'Design Requested',}/*** Order lines built from the Plinth seed (apps/dashboard/src/plinth-seed.ts),* one row per order number. Product-level fields that the seed does not carry* are empty rather than made up: a fixture that fabricates them would make the* render look right while testing nothing about how real data flows.*/const LINES: OrderLine[] = PLINTH_SEED.map((o, i) => ({orderId: o.orderId ?? 90000 + i,ordersProductsId: null,productId: null,orderStatus: o.orderStatus,orderStatusId: 0,/*** A real product status, not the order's echoed back. Mirroring the order* status would put order-only statuses such as `Proof Out` into the product* field, where they hit the unknown-means-ours default, and no test could* exercise the two fields disagreeing, which is the entire subject of the* rule they feed (see classifyLine).*/productStatus: PRODUCT_FOR_ORDER[o.orderStatus] ?? o.orderStatus,productStatusId: null,// The export carries neither a product title nor a customer name: it is an// order-level report. Echoing the order number into both columns would make// three columns say the same thing. Empty is honest; the render turns it into// a printed "not in export".productTitle: '',quantity: 1,price: 0,customer: '',company: null,placedAt: o.placedAt,amount: 0,vendor: null,userGroup: o.userGroup,productionOwner: displayName(o.productionOwner),accountOwner: displayName(o.accountOwner),// Against the export date, not today. See the note in plinth-seed.ts:// measuring a frozen snapshot against a live clock ages it one day per day.ageDays: o.placedAt? Math.floor((Date.parse(SEED_EXPORTED_AT) - Date.parse(o.placedAt)) / 86_400_000): null,}))/*** Bitrix tasks, shaped like the live ones, including the junk, because the junk* is the point. Duplicate FedEx rows and Bitrix's onboarding defaults turn up* in real accounts and must be visibly excluded rather than quietly absent.*/const TASKS: Task[] = [// Junk, attributed the way it tends to be live: the FedEx series to a// departed account and the test rows to a meeting room....repeat(16, (i) => task(`f${i}`, 'FedEx Invoice Review', BITRIX.departed, null)),...repeat(4, (i) => task(`t${i}`, 'test', BITRIX.meetingRoom, null)),task('b1', 'Fill out profile', BITRIX.nora, null),task('b2', 'Invite colleagues', BITRIX.nora, null),task('b3', 'Download Bitrix24 application', BITRIX.nora, null),// Real work, held by the roster.task('r1', 'Update the site hosting plan', BITRIX.ines, '2026-07-27'),task('r2', 'Compress product images on the banner pages', BITRIX.ines, '2026-08-14'),task('r3', 'Book team photos for the About page', BITRIX.theo, '2026-07-15'),task('r4', 'Tidewater Studios / Event backdrop design', BITRIX.marco, null),task('r5', 'Research trade show organizers', BITRIX.sam, '2026-08-20'),task('r6', 'Meet with Nora', BITRIX.hazel, null),// Real work held by an account off the roster. Expected, and must be counted// on Data health rather than absorbed. Most real tasks can be like this,// and the board deliberately shows none of them.task('r7', 'Fix the old quote form (April, 2024)', BITRIX.offRoster, null),// No responsible party at all. Different from off-roster and must not merge// with it: nobody has been asked to do this one.task('r8', 'Test a new product page layout', null, null),]/**/*** Ninety team ids, from config/ninety-teams.ts.** Live data mixes all four cases (Leadership, Operations, All Hands, no team),* and fixtures are the contract: a single made-up team id on every row would* make `isRestrictedTeam()` say yes to everything and leave the All Hands and* untagged paths untested.*/const TEAM = {leadership: '3daec27fdbd4c3a983e95864',operations: '20b8a35b8edd3ed30b2d8f30',allHands: 'eb3d8bb215506d3263d47cac',} as constconst TEAM_NAME: Record<string, string> = {[TEAM.leadership]: 'Leadership Team',[TEAM.operations]: 'Operations Team',[TEAM.allHands]: 'All Hands',}const todo = (id: string,title: string,ownerName: string,dueInDays: number,opts: { milestoneId?: string; teamId?: string | null } = {},): Todo => {const due = new Date(Date.now() + dueInDays * 86_400_000).toISOString()return {id,title,description: '',descriptionHtml: '',dueDate: due,dueDateLocal: localDay(due, MERIDIAN_TZ),dueLabel: '',completed: false,completedDate: null,archived: false,deleted: false,owner: { id: `u-${ownerName.toLowerCase().replace(/\W+/g, '')}`, name: ownerName },createdBy: { id: 'u-seed', name: ownerName },// `teamId: undefined` in opts means All Hands; an explicit `null` means the// to-do carries no team at all, which isRestrictedTeam() treats as restricted.teamId: opts.teamId === undefined ? TEAM.allHands : opts.teamId,teamName: opts.teamId === undefined ? TEAM_NAME[TEAM.allHands]! : (TEAM_NAME[opts.teamId ?? ''] ?? null),isPersonal: false,repeat: null,seriesId: null,milestoneId: opts.milestoneId ?? null,followers: [],createdAt: null,raw: { _id: id } as Todo['raw'],}}const LEADERSHIP = { teamId: TEAM.leadership }/** No team at all. Live rows like this exist, and they count as restricted. */const UNTAGGED = { teamId: null }/*** Ninety to-dos, shaped like a live company sweep.** The important property being reproduced here is not the count: it is that* the hiring to-do sits on three people and the photo refresh on two. Shared* to-dos are common live, and a fixture set of purely solo to-dos would let the* assignments-vs-commitments distinction go untested.** Names are roster names because the people board keys on them; titles are* invented.*/const TODOS: Todo[] = [// One commitment, three owners. The case that breaks a naive count. Hiring is// Leadership work, so this is also the shared-AND-restricted case.todo('n1', 'Hire a client success associate & write the hiring SOP', 'Sam Whitaker', 37, LEADERSHIP),todo('n2', 'Hire a client success associate & write the hiring SOP', 'Nora Lindqvist', 37, LEADERSHIP),todo('n3', 'Hire a client success associate & write the hiring SOP', 'Theo Bassett', 37, LEADERSHIP),// One commitment, two owners. All Hands: the rollup the Google Sheet sits// behind.todo('n4', 'Weekly product photo refresh', 'Ines Carvalho', 5),todo('n5', 'Weekly product photo refresh', 'Rafael Montero', 5),// Solo work, including two that are properly late.todo('n6', 'Finish the site search project with Northgate', 'Sam Whitaker', 11, { milestoneId: 'ms-ai' }),todo('n7', 'Send Northgate feedback on the quote form', 'Sam Whitaker', -9),todo('n8', 'Follow up with Northgate on the redirect list', 'Sam Whitaker', -3),// Leadership, late, and owned by the one person who reads the exec view.todo('n9', 'Update KPIs for client success and production', 'Nora Lindqvist', 11, LEADERSHIP),todo('n10', 'Find a second supplier for tent frames', 'Marco Ibáñez', 20, {teamId: TEAM.operations,}),// Carries no team, so it is restricted for want of a tag rather than by intent.todo('n11', 'Coordinate backdrop images', 'Theo Bassett', 4, UNTAGGED),]/*** Content Creation tracker rows, shaped like the live sheet.** The properties being reproduced, in order of how badly each one bit:** - Owners are first names, because a human types them. "Nora & Sam" is one* row owned by two, and it is here so the shared-owner split stays tested.* - Statuses include the ones with meaning and a blank. Live rows can be* blank; a fixture with none would let the unclassified path go untested* (rule 2).* - `Ready to Launch` is present, because it is the judgement call in* config/content-tracker.ts and the one a reader may want to overturn.* - Titles carry commas and a URL, because the CSV parser has to survive both.*/const sheetTask = (id: string,tab: string,title: string,owners: string[],status: string,opts: { notes?: string; deadline?: string; sku?: string } = {},): TrackerTask => ({id,tab,title,owners,status,notes: opts.notes ?? '',deadline: opts.deadline ?? null,sku: opts.sku ?? null,shared: owners.length > 1,})const SHEET_TASKS: TrackerTask[] = [sheetTask('0:75', 'Images', 'Backdrop: https://meridiandisplay.example/10ft-fabric-backdrop-(straight)/', ['Theo'], 'In Progress', { notes: 'Send to the retoucher 08/04/2026' }),sheetTask('0:78', 'Images', 'Canopy Tent Product Images 20x20ft', ['Marco'], 'In Progress', { sku: 'CT20', deadline: '08/21/2026' }),sheetTask('0:79', 'Images', 'Canopy Tent Product Images 10x10ft', ['Rafael'], 'In Progress', { sku: 'CT10', deadline: '08/21/2026' }),sheetTask('0:81', 'Images', 'Redraw the site icons in a simpler style', ['Ines'], 'In Progress'),sheetTask('0:82', 'Images', 'Fabric sample photos: custom size printed graphics', ['Sam'], 'Not Started'),sheetTask('0:83', 'Images', 'Reuse/adapt existing table throw images (2 rows), fix large throw profile image', ['Marco'], 'Not Started', { sku: 'TT8', deadline: '08/28/2026' }),sheetTask('0:85', 'Images', 'Options image for flags and banners on the product page', ['Rafael'], 'In Adjustments', { deadline: '08/28/2026' }),// Finished by them, waiting on the launch step. Context, never workload.sheetTask('0:71', 'Images', 'New image for the feather flag back view', ['Sam'], 'Ready to Launch'),sheetTask('0:73', 'Images', 'book a design consultation image', ['Marco'], 'Ready to Launch', { deadline: '08/21/2026' }),sheetTask('0:74', 'Images', 'Create 10ft image for canopy tents', ['Rafael'], 'Ready to Launch'),// Blank status. Unclassified, counted, and named on the page, not defaulted.sheetTask('0:86', 'Images', 'Mesh banner in-use photo improvement', ['Rafael'], ''),sheetTask('0:88', 'Images', 'Improve the fabric banner photo to show real texture', ['Marco'], ''),// Two owners on one row.sheetTask('100000001:5', 'Video & Photo', 'Photo shoot for every feather flag size', ['Nora', 'Sam'], 'In Progress'),// Done.sheetTask('100000002:2', 'Copy & SEO', 'Add FAQs to the canopy tent page', ['Ines'], 'Launched'),sheetTask('100000003:2', 'Newsletter', 'Create newsletter outline + main image', ['Marco'], 'Launched'),]/*** Fixture meetings, with attendee lists, derived from the frozen Fathom seed.** The participants are synthesised because the seed records action items and* their assignees but not who was in the room. A fixture with no attendees* would make every meeting visible to nobody, and the gate would pass its* tests by rendering nothing at all: a fixture that agrees with a broken gate.** So each meeting's attendees are its item owners, and one meeting is* deliberately restricted to a subset of the roster: the leadership case is the* whole reason the gate exists, so `pnpm dashboard --fixtures` has to contain a* meeting that most viewers must not see.*/export const FIXTURE_MEETINGS: MeetingRecord[] = FATHOM_SEED.slice(0, 8).map((m, i) => {const owners: string[] = []const items = m.actionItems.map((item) => {const a = attributeFathomAssignee(item.assignee)const owner = a.kind === 'roster' ? a.person.name : nullif (owner && !owners.includes(owner)) owners.push(owner)return {text: item.text,owner,externalOwner: a.kind === 'external' ? a.label : null,unknownOwner: a.kind === 'unknown' ? a.label : null,playbackUrl: `${m.url}?timestamp=60`,}})// Meeting 0 is the leadership case: Sam and Nora only, whatever its items// say. Every gating test in the suite leans on this one.const participants = i === 0 ? ['Sam Whitaker', 'Nora Lindqvist'] : ownersreturn {recordingId: m.recordingId,title: i === 0 ? 'Leadership only: comp and headcount' : m.title,// Recent, not the seed's frozen date. Commitments are windowed to// COMMITMENT_WINDOW_DAYS, so seed-dated meetings fall outside it and the// fixture page renders an empty card, which would also let every gating// test pass by showing nothing at all. Days 1..8 keep all eight inside.date: new Date(Date.now() - (i + 1) * 86_400_000).toISOString(),url: m.url,participants,externals: [],unmatchedParticipants: [],items,}})export const FIXTURE_SNAPSHOT: Snapshot = {syncedAt: new Date().toISOString(),mode: 'fixtures',fixtureCapturedAt: SEED_EXPORTED_AT,leads: { ok: true, data: LEADS, error: null },orderLines: { ok: true, data: LINES, error: null },tasks: { ok: true, data: TASKS, error: null },ninety: { ok: true, data: TODOS, error: null },ninetySpread: assignmentSpread(TODOS),sheet: { ok: true, data: SHEET_TASKS, error: null },sheetTabs: {ok: [{ label: 'Images', gid: '0', rows: 12 },{ label: 'Video & Photo', gid: '100000001', rows: 1 },{ label: 'Copy & SEO', gid: '100000002', rows: 1 },{ label: 'Newsletter', gid: '100000003', rows: 1 },],failed: [],},// Invented leads, so no window applies and nothing was truncated.leadWindow: { truncated: false, pages: 1, sinceDays: null },// A complete sweep, so the fixture page shows no truncation caveat. `lines` is// left empty deliberately: PLINTH_SEED below is the fixture's line data, and two// copies of it would drift.//// Coverage must read full, though: the seed carries an assignment for every// line, so zero here would fire the "fields stopped arriving" guard over sample// data and hang a failure pill on a perfectly healthy fixture run.orderWindow: {lines: [],truncated: false,pages: 1,assignmentFields: { production: LINES.length, accountOwner: LINES.length, total: LINES.length },},assignmentWarnings: [],// Fixture meetings carry participants so the gate is exercised with no// credentials: FIXTURE_MEETINGS below gives each seed meeting an attendee// list, and a fixture render must still withhold a meeting from someone who// was not in it. A fixture that skipped the gate would let a leak reach// `pnpm dashboard --fixtures` unnoticed.meetings: { ok: true, data: FIXTURE_MEETINGS, error: null },fathomConfigured: true,// Three keys, all clean: the shape of a good live run, so a fixture render// shows the same source pill a real one does. The partial and all-dark cases// are built by spreading over this in the tests rather than shipped here: a// fixture that is permanently degraded trains everyone to ignore the pill.// `returned` sums above FIXTURE_MEETINGS.length on purpose: two keys can// return the same meeting and the dedupe happens after the count.fathomSources: [{ owner: 'Sam Whitaker', ok: true, error: null, returned: 8, truncated: false },{ owner: 'Ines Carvalho', ok: true, error: null, returned: 3, truncated: false },{ owner: 'Theo Bassett', ok: true, error: null, returned: 2, truncated: false },],}