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

my-day.test.ts

Language TypeScript14.6 KB360 lines

apps/dashboard/test/my-day.test.ts

  1. /**
  2. * My Day, and the number above the list.
  3. *
  4. * The header counts Ninety to-dos into the load, so the list must include them
  5. * too; a card built from order lines and Bitrix tasks alone prints a number and
  6. * then says "Nothing on this board right now." Several people hold no order
  7. * lines at all, so for them it would always be a number above an empty list
  8. * (rule 9).
  9. *
  10. * These tests assert the reconciliation rather than any wording: whatever the
  11. * header counts, the card must be able to account for, either by listing it or
  12. * by saying out loud that it did not.
  13. */
  14. import { describe, expect, it } from 'vitest'
  15. import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'
  16. import { headline, type Snapshot } from '../src/collect.js'
  17. import { buildViewModel, todoItem } from '../src/view-model.js'
  18. import { render } from '../src/render.js'
  19. import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'
  20. import { esc } from '../src/html.js'
  21. import { OWN_END, OWN_START } from '../src/own-list-view.js'
  22. import { MERIDIAN_PEOPLE } from '../../../config/meridian-people.js'
  23. import type { Todo } from '@meridian/ninety-client'
  24. const DAY = '2026-08-12'
  25. const emailOf = (name: string): string => {
  26. const p = MERIDIAN_PEOPLE.find((x) => x.name === name)
  27. if (!p?.email) throw new Error(`no email for ${name}`)
  28. return p.email
  29. }
  30. const vmFor = (name: string, snap: Snapshot = FIXTURE_SNAPSHOT) =>
  31. buildViewModel(snap, headline(snap), [], DAY, viewerFor(emailOf(name)))
  32. const cardFor = (name: string, snap: Snapshot = FIXTURE_SNAPSHOT) => {
  33. const card = vmFor(name, snap).myDays.find((d) => d.person.name === name)
  34. if (!card) throw new Error(`no My Day card for ${name}`)
  35. return card
  36. }
  37. /** A to-do shaped like a live one, so the roster join is exercised for real. */
  38. const todoOf = (over: Partial<Todo>): Todo => ({
  39. id: 't-x',
  40. title: 'A to-do',
  41. description: '',
  42. descriptionHtml: '',
  43. dueDate: null,
  44. dueDateLocal: null,
  45. dueLabel: '',
  46. completed: false,
  47. completedDate: null,
  48. archived: false,
  49. deleted: false,
  50. owner: { id: 'u-x', name: 'Someone' },
  51. createdBy: { id: 'u-x', name: 'Someone' },
  52. teamId: null,
  53. teamName: null,
  54. isPersonal: false,
  55. repeat: null,
  56. seriesId: null,
  57. milestoneId: null,
  58. followers: [],
  59. createdAt: null,
  60. raw: { _id: 't-x' } as Todo['raw'],
  61. ...over,
  62. })
  63. const withTodos = (todos: Todo[]): Snapshot => ({
  64. ...FIXTURE_SNAPSHOT,
  65. ninety: { ok: true, data: todos, error: null },
  66. })
  67. describe('the header number and the list agree', () => {
  68. it('accounts for every to-do it counts, on every card', () => {
  69. // The bug, stated as arithmetic. `load` counts orders + tasks + to-dos; the
  70. // card must either list a to-do or declare it withheld.
  71. for (const p of MERIDIAN_PEOPLE) {
  72. const d = cardFor(p.name)
  73. expect(d.todos + d.todosWithheld).toBe(d.person.todos ?? 0)
  74. }
  75. })
  76. it('gives Ines, Theo and Nora a list instead of a number over nothing', () => {
  77. // These three hold no Plinth assignment, so without to-dos their card could
  78. // never show a row, whatever they were carrying.
  79. for (const name of ['Ines Carvalho', 'Theo Bassett', 'Nora Lindqvist']) {
  80. const d = cardFor(name)
  81. expect(d.person.plinthOurMove).toBeNull()
  82. expect(d.todos).toBeGreaterThan(0)
  83. expect(d.items.length).toBeGreaterThanOrEqual(d.todos)
  84. }
  85. })
  86. it('puts the to-do titles in the bytes of the page that person is served', () => {
  87. // The view model agreeing is not the same as the page showing it.
  88. const out = render(
  89. FIXTURE_SNAPSHOT,
  90. headline(FIXTURE_SNAPSHOT),
  91. [],
  92. DAY,
  93. null,
  94. viewerFor(emailOf('Nora Lindqvist')),
  95. )
  96. for (const t of cardFor('Nora Lindqvist').items) expect(out).toContain(esc(t.what))
  97. })
  98. })
  99. describe('to-dos are attributed through the roster, not by spelling', () => {
  100. it('matches on the pinned Ninety id when the display name has drifted', () => {
  101. // Ninety says "Samuel Whitaker" where the roster says "Sam Whitaker". An
  102. // equality join on the name would drop them to zero, silently.
  103. const sam = MERIDIAN_PEOPLE.find((p) => p.name === 'Sam Whitaker')!
  104. const snap = withTodos([
  105. todoOf({
  106. id: 'drifted',
  107. title: 'Owned by a drifted spelling',
  108. owner: { id: sam.ninetyId!, name: 'Samuel Whitaker' },
  109. }),
  110. ])
  111. expect(cardFor('Sam Whitaker', snap).items.map((i) => i.what)).toContain(
  112. 'Owned by a drifted spelling',
  113. )
  114. })
  115. it('leaves an unmapped owner off every card rather than guessing one', () => {
  116. const snap = withTodos([
  117. todoOf({ id: 'orphan', title: 'Nobody owns this', owner: { id: 'u-ghost', name: 'A Ghost' } }),
  118. ])
  119. for (const p of MERIDIAN_PEOPLE) {
  120. expect(cardFor(p.name, snap).items.map((i) => i.what)).not.toContain('Nobody owns this')
  121. }
  122. })
  123. })
  124. describe("another person's card carries no to-do titles", () => {
  125. it('withholds them and says how many, rather than dropping them', () => {
  126. // My Day renders only the viewer's own card today. This is the guard for the
  127. // day someone adds a surface that renders another person's. The previous
  128. // design of this page shipped every person's card in one document.
  129. const vm = vmFor('Nora Lindqvist')
  130. const other = vm.myDays.find((d) => d.person.name === 'Marco Ibáñez')!
  131. expect(other.todos).toBe(0)
  132. expect(other.todosWithheld).toBe(other.person.todos ?? 0)
  133. expect(other.todosWithheld).toBeGreaterThan(0)
  134. })
  135. it('discloses nothing to a caller that forgets the viewer argument', () => {
  136. const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], DAY)
  137. for (const d of vm.myDays) expect(d.todos).toBe(0)
  138. })
  139. it('does not put another person’s to-do title in the page bytes', () => {
  140. const noraTitles = cardFor('Nora Lindqvist').items.map((i) => i.what)
  141. const out = render(
  142. FIXTURE_SNAPSHOT,
  143. headline(FIXTURE_SNAPSHOT),
  144. [],
  145. DAY,
  146. null,
  147. viewerFor(emailOf('Marco Ibáñez')),
  148. )
  149. const marcoTitles = new Set(cardFor('Marco Ibáñez').items.map((i) => i.what))
  150. for (const title of noraTitles) {
  151. if (marcoTitles.has(title)) continue // genuinely shared work, both own it
  152. expect(out).not.toContain(esc(title))
  153. }
  154. })
  155. })
  156. describe('a dead source is not a clear desk', () => {
  157. it('names the failed source instead of reporting an empty board', () => {
  158. // A 403 from /todos/company is a role problem, and Nora's whole load comes
  159. // through it. Rendering that as "nothing to do" is the prime invariant
  160. // inverted.
  161. // Both of Nora's sources are cut, not just Ninety: she holds a tracker row
  162. // too, so a Ninety-only outage would not empty her card. The property under test is that an
  163. // empty list caused by a dead source never reads as a clear desk.
  164. const down: Snapshot = {
  165. ...FIXTURE_SNAPSHOT,
  166. ninety: { ok: false, data: [], error: 'HTTP 403' },
  167. sheet: { ok: false, data: [], error: 'HTTP 403' },
  168. }
  169. const d = cardFor('Nora Lindqvist', down)
  170. expect(d.items).toHaveLength(0)
  171. expect(d.sourcesDown).toContain('Ninety to-dos')
  172. const out = render(down, headline(down), [], DAY, null, viewerFor(emailOf('Nora Lindqvist')))
  173. expect(out).not.toContain('Nothing on this board right now.')
  174. })
  175. it('reports a clear desk as a clear desk when every source is up', () => {
  176. const quiet: Snapshot = {
  177. ...FIXTURE_SNAPSHOT,
  178. ninety: { ok: true, data: [], error: null },
  179. tasks: { ok: true, data: [], error: null },
  180. orderLines: { ok: true, data: [], error: null },
  181. sheet: { ok: true, data: [], error: null },
  182. }
  183. expect(cardFor('Nora Lindqvist', quiet).sourcesDown).toHaveLength(0)
  184. })
  185. })
  186. describe('late means past a review gate, not overdue by the calendar', () => {
  187. it('does not call a to-do late before its first Monday', () => {
  188. // Nothing at Meridian closes outside a Monday review, so raw overdue counts
  189. // inflate every weekend and reset every Monday, which trains people to
  190. // ignore the number. Due Saturday 2026-08-08, read Sunday the 9th: overdue
  191. // by the calendar, but no review has passed, so it is not late.
  192. const t = todoOf({ dueDate: '2026-08-09T04:59:59.999Z', dueDateLocal: '2026-08-08' })
  193. const sunday = todoItem(t, new Date('2026-08-09T18:00:00Z'))
  194. expect(sunday.late).toBe(false)
  195. expect(sunday.age).toBe('1d over')
  196. // Same to-do, read after the Monday review it survived.
  197. const tuesday = todoItem(t, new Date('2026-08-11T18:00:00Z'))
  198. expect(tuesday.late).toBe(true)
  199. expect(tuesday.meta).toContain('past 1 review gate')
  200. })
  201. it('says "no due date" rather than inventing one', () => {
  202. const undated = todoItem(todoOf({}), new Date('2026-08-11T18:00:00Z'))
  203. expect(undated.age).toBe('–')
  204. expect(undated.late).toBe(false)
  205. expect(undated.meta).toContain('no due date')
  206. })
  207. it('counts late to-dos in the review-gate total under the list', () => {
  208. // lateCount drove a footer sentence built from Bitrix tasks only, so a card
  209. // could print "nothing is past a review gate" above an overdue to-do.
  210. const d = cardFor('Sam Whitaker')
  211. const lateItems = d.items.filter((i) => i.late)
  212. expect(d.lateCount).toBe(lateItems.length)
  213. expect(lateItems.some((i) => i.meta.includes('Ninety to-do'))).toBe(true)
  214. })
  215. })
  216. describe('recurrence is not collapsed away', () => {
  217. it('lists both instances of a series, because the header counts both', () => {
  218. // One of Ines's live to-dos appears twice, due a week apart. todoLoad()
  219. // counts every instance; collapsing only the LIST would put the number and
  220. // the list back out of agreement, which is the bug this file is about.
  221. const nora = MERIDIAN_PEOPLE.find((p) => p.name === 'Nora Lindqvist')!
  222. const snap = withTodos([
  223. todoOf({
  224. id: 'r1',
  225. title: 'Weekly image review',
  226. owner: { id: nora.ninetyId!, name: 'Nora Lindqvist' },
  227. seriesId: 's-1',
  228. repeat: 'Weekly',
  229. dueDate: '2026-08-08T04:59:59.999Z',
  230. dueDateLocal: '2026-08-07',
  231. }),
  232. todoOf({
  233. id: 'r2',
  234. title: 'Weekly image review',
  235. owner: { id: nora.ninetyId!, name: 'Nora Lindqvist' },
  236. seriesId: 's-1',
  237. repeat: 'Weekly',
  238. dueDate: '2026-08-15T04:59:59.999Z',
  239. dueDateLocal: '2026-08-14',
  240. }),
  241. ])
  242. const d = cardFor('Nora Lindqvist', snap)
  243. expect(d.items.filter((i) => i.what === 'Weekly image review')).toHaveLength(2)
  244. expect(d.todos).toBe(d.person.todos)
  245. // And the repeat is stated, so a title appearing twice explains itself.
  246. // Scoped to the to-do rows: the card also carries tracker and order rows,
  247. // and asserting over all of them tested the fixture rather than the rule.
  248. for (const i of d.items.filter((x) => x.meta.startsWith('Ninety to-do'))) {
  249. expect(i.meta).toContain('Weekly')
  250. }
  251. })
  252. })
  253. describe('an order and a to-do are not the same measurement', () => {
  254. it('labels a to-do row by its due date, never as a bare age', () => {
  255. // The age column shows "34d" for an order line, meaning days waiting. A
  256. // to-do showing a bare "9d" in the same column invites a comparison between
  257. // two different things.
  258. const d = cardFor('Sam Whitaker')
  259. for (const i of d.items.filter((x) => x.meta.includes('Ninety to-do'))) {
  260. expect(i.age).toMatch(/over$|^due in |^–$/)
  261. }
  262. })
  263. })
  264. describe('COMPANY_VIEWER has no card of its own', () => {
  265. it('builds no My Day for an address the roster does not know', () => {
  266. const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], DAY, COMPANY_VIEWER)
  267. expect(COMPANY_VIEWER.ownBriefing).toBeNull()
  268. for (const d of vm.myDays) expect(d.todos).toBe(0)
  269. })
  270. })
  271. describe('the private list sits in this card, and only in its owner’s copy', () => {
  272. const pageFor = (email: string | null): string => {
  273. const viewer = email ? viewerFor(email) : COMPANY_VIEWER
  274. return render(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], DAY, null, viewer)
  275. }
  276. it('every roster viewer’s page carries the markers exactly once', () => {
  277. for (const p of MERIDIAN_PEOPLE) {
  278. if (!p.email) continue
  279. const html = pageFor(p.email)
  280. expect(html.split(OWN_START)).toHaveLength(2)
  281. expect(html.split(OWN_END)).toHaveLength(2)
  282. }
  283. })
  284. it('an address the roster does not know carries them zero times', () => {
  285. // Not a hidden div and not an empty one: nothing at all. A private list on
  286. // a shared mailbox is a list everyone who signs into it can read.
  287. const html = pageFor(null)
  288. expect(html).not.toContain(OWN_START)
  289. expect(html).not.toContain(OWN_END)
  290. })
  291. it('the markers sit inside the VIEWER’S OWN card and no other', () => {
  292. // The guard that looks redundant. myDaySurface renders only the viewer's
  293. // own card, so today this cannot go wrong, and there is one marker pair
  294. // per page, into which the Worker stamps the viewer's lines. If anything
  295. // ever renders a second person's card, a placeholder sitting in it would
  296. // put the viewer's private list under somebody else's name. Asserted
  297. // against the rendered page rather than the private function, so it holds
  298. // however the card gets built.
  299. for (const p of MERIDIAN_PEOPLE) {
  300. if (!p.email) continue
  301. const html = pageFor(p.email)
  302. const at = html.indexOf(OWN_START)
  303. expect(at).toBeGreaterThan(-1)
  304. const panelsBefore = [...html.slice(0, at).matchAll(/data-person-panel="([^"]*)"/g)]
  305. // The last card opened before the markers is the one they are inside.
  306. expect(panelsBefore.at(-1)?.[1]).toBe(esc(p.name))
  307. }
  308. })
  309. it('no private text is in the pre-rendered page, only the placeholder is', () => {
  310. // The pages this board serves are written to KV by a cron with no viewer
  311. // session, so whatever ever leaks out of KV cannot be somebody's list.
  312. const html = pageFor('hello@meridiandisplay.example')
  313. const between = html.slice(html.indexOf(OWN_START), html.indexOf(OWN_END))
  314. expect(between).toContain('live')
  315. expect(between).not.toContain('<form')
  316. })
  317. it('“on my board” is unchanged by the existence of a private list', () => {
  318. // The decision this pins: a private count folded into `load` would leak
  319. // into the workload panel and the company narration, which both read
  320. // `load`, and it would render a person no source covers as a number
  321. // instead of a dash, the prime invariant broken on the tile it was written
  322. // for. `load` must keep coming from the sources and nowhere else.
  323. const viewer = viewerFor('frontdesk@meridiandisplay.example')
  324. const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], DAY, viewer)
  325. for (const d of vm.myDays) {
  326. expect(d.person.load).toBe(vm.people.find((p) => p.name === d.person.name)?.load)
  327. }
  328. })
  329. })