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

dates.test.ts

Language TypeScript8.5 KB223 lines

packages/ninety-client/test/dates.test.ts

  1. /**
  2. * Due dates must not shift by a day between the wire and the page.
  3. *
  4. * The expected values below are not invented. They are what the Ninety UI
  5. * showed in a screenshot, paired with the exact wire values the API returned for
  6. * the same to-dos. If these ever fail, the dashboard is lying about dates again.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import {
  10. DEFAULT_REVIEW_DAYS,
  11. MERIDIAN_TZ,
  12. calendarDaysBetween,
  13. dueLabel,
  14. isActuallyLate,
  15. isOverdue,
  16. localDay,
  17. localDayBounds,
  18. reviewGatesMissed,
  19. shortDate,
  20. } from '../src/dates.js'
  21. import { RawTodoSchema, normalizeTodo, UNKNOWN_PERSON } from '../src/types.js'
  22. import fixture from './fixtures/todos.query.json' with { type: 'json' }
  23. /**
  24. * Ground truth. Left column is what came over the wire, right column is what the
  25. * Ninety UI displayed for that same to-do.
  26. */
  27. const GROUND_TRUTH = [
  28. { wire: '2026-08-08T04:59:59.999Z', ui: 'Aug 7', day: '2026-08-07' },
  29. { wire: '2026-08-15T04:59:59.999Z', ui: 'Aug 14', day: '2026-08-14' },
  30. { wire: '2026-07-25T04:59:59.999Z', ui: 'Jul 24', day: '2026-07-24' },
  31. { wire: '2026-09-01T04:59:59.999Z', ui: 'Aug 31', day: '2026-08-31' },
  32. ]
  33. describe('localDay: the bug', () => {
  34. it.each(GROUND_TRUTH)('$wire renders as $ui, not the UTC day', ({ wire, day }) => {
  35. expect(localDay(wire, MERIDIAN_TZ)).toBe(day)
  36. })
  37. it('proves slicing the ISO string is wrong', () => {
  38. // This is what the first version of the smoke test did.
  39. expect('2026-08-08T04:59:59.999Z'.slice(0, 10)).toBe('2026-08-08')
  40. expect(localDay('2026-08-08T04:59:59.999Z')).toBe('2026-08-07')
  41. })
  42. it('handles a winter date, where the UTC offset is -6 not -5', () => {
  43. // End of day Jan 15 CST is 06:00Z on Jan 16, not 05:00Z. A hardcoded
  44. // five-hour subtraction would get this wrong.
  45. expect(localDay('2026-01-16T05:59:59.999Z')).toBe('2026-01-15')
  46. })
  47. it('rejects garbage rather than silently returning a wrong day', () => {
  48. expect(() => localDay('not a date')).toThrow(RangeError)
  49. })
  50. })
  51. describe('shortDate matches how the Ninety UI writes dates', () => {
  52. it.each(GROUND_TRUTH)('$wire -> $ui', ({ wire, ui }) => {
  53. expect(shortDate(wire)).toBe(ui)
  54. })
  55. })
  56. describe('isOverdue uses instant comparison, which was never broken', () => {
  57. const now = new Date('2026-08-09T01:02:45.964Z') // a live smoke-test run
  58. it('a to-do due end of Aug 7 local is overdue on the evening of Aug 8', () => {
  59. expect(isOverdue('2026-08-08T04:59:59.999Z', now)).toBe(true)
  60. })
  61. it('a to-do due end of Aug 14 local is not yet overdue', () => {
  62. expect(isOverdue('2026-08-15T04:59:59.999Z', now)).toBe(false)
  63. })
  64. it('is not fooled by the last minute of the local day', () => {
  65. // 11:58 PM Central on Aug 7: the to-do is due at 11:59:59.999 and is not
  66. // overdue yet, even though the UTC date has already rolled over to Aug 8.
  67. const justBefore = new Date('2026-08-08T04:58:00.000Z')
  68. expect(isOverdue('2026-08-08T04:59:59.999Z', justBefore)).toBe(false)
  69. })
  70. it('treats a missing due date as not overdue', () => {
  71. expect(isOverdue(null, now)).toBe(false)
  72. })
  73. })
  74. describe('calendarDaysBetween counts whole days, ignoring time of day', () => {
  75. const now = new Date('2026-08-09T01:02:45.964Z') // evening of Aug 8 Central
  76. it('counts a to-do due end of Aug 7 as one day overdue', () => {
  77. expect(calendarDaysBetween(now, '2026-08-08T04:59:59.999Z')).toBe(-1)
  78. })
  79. it('counts the Jul 24 item as 15 days overdue, matching the smoke test', () => {
  80. expect(calendarDaysBetween(now, '2026-07-25T04:59:59.999Z')).toBe(-15)
  81. })
  82. it('counts forward correctly', () => {
  83. expect(calendarDaysBetween(now, '2026-08-15T04:59:59.999Z')).toBe(6)
  84. })
  85. it('does not round a late-evening due time into an extra day', () => {
  86. // 23:59 today is 0 days away, not 1.
  87. expect(calendarDaysBetween('2026-08-09T13:00:00Z', '2026-08-10T04:59:59.999Z')).toBe(0)
  88. })
  89. })
  90. describe('dueLabel', () => {
  91. const now = new Date('2026-08-09T01:02:45.964Z')
  92. it('reads the way the UI does, with a relative hint', () => {
  93. expect(dueLabel('2026-08-08T04:59:59.999Z', now)).toBe('Aug 7 (1 day overdue)')
  94. expect(dueLabel('2026-07-25T04:59:59.999Z', now)).toBe('Jul 24 (15 days overdue)')
  95. expect(dueLabel('2026-08-15T04:59:59.999Z', now)).toBe('Aug 14 (in 6 days)')
  96. })
  97. it('says today and tomorrow rather than "in 0 days"', () => {
  98. expect(dueLabel('2026-08-09T04:59:59.999Z', now)).toBe('Aug 8 (due today)')
  99. expect(dueLabel('2026-08-10T04:59:59.999Z', now)).toBe('Aug 9 (due tomorrow)')
  100. })
  101. it('handles no due date', () => {
  102. expect(dueLabel(null, now)).toBe('no due date')
  103. })
  104. })
  105. describe('localDayBounds, for "what changed since yesterday"', () => {
  106. it('spans a full local day during CDT', () => {
  107. const { startMs, endMs } = localDayBounds('2026-08-07')
  108. expect(new Date(startMs).toISOString()).toBe('2026-08-07T05:00:00.000Z')
  109. expect(endMs - startMs).toBe(86_400_000)
  110. })
  111. it('spans a full local day during CST', () => {
  112. const { startMs } = localDayBounds('2026-01-15')
  113. expect(new Date(startMs).toISOString()).toBe('2026-01-15T06:00:00.000Z')
  114. })
  115. it('contains a to-do due at end of that local day', () => {
  116. const { startMs, endMs } = localDayBounds('2026-08-07')
  117. const due = Date.parse('2026-08-08T04:59:59.999Z')
  118. expect(due).toBeGreaterThanOrEqual(startMs)
  119. expect(due).toBeLessThan(endMs)
  120. })
  121. })
  122. describe('reviewGatesMissed: Meridian closes to-dos only on Mondays', () => {
  123. // Saturday evening Aug 8, the moment the real smoke test ran.
  124. const now = new Date('2026-08-09T01:02:45.964Z')
  125. it('a to-do due Friday Aug 7 has missed zero gates on Saturday', () => {
  126. // Next review is Monday Aug 10. Overdue on paper, on time in practice.
  127. expect(reviewGatesMissed('2026-08-08T04:59:59.999Z', now)).toBe(0)
  128. expect(isOverdue('2026-08-08T04:59:59.999Z', now)).toBe(true)
  129. expect(isActuallyLate('2026-08-08T04:59:59.999Z', now)).toBe(false)
  130. })
  131. it('the Jul 24 item has missed two gates and is genuinely late', () => {
  132. // Mondays Jul 27 and Aug 3 both passed without it being closed.
  133. expect(reviewGatesMissed('2026-07-25T04:59:59.999Z', now)).toBe(2)
  134. expect(isActuallyLate('2026-07-25T04:59:59.999Z', now)).toBe(true)
  135. })
  136. it('turns "2 overdue" into "1 actually late" across the real open items', () => {
  137. const dues = ['2026-07-25T04:59:59.999Z', '2026-08-08T04:59:59.999Z', '2026-09-01T04:59:59.999Z']
  138. expect(dues.filter((d) => isOverdue(d, now))).toHaveLength(2)
  139. expect(dues.filter((d) => isActuallyLate(d, now))).toHaveLength(1)
  140. })
  141. it('does not count the review day an item is due on', () => {
  142. // Due Monday Aug 10, checked Tuesday Aug 11: the next chance is Aug 17.
  143. const tue = new Date('2026-08-12T01:00:00Z')
  144. expect(reviewGatesMissed('2026-08-11T04:59:59.999Z', tue)).toBe(0)
  145. })
  146. it('counts a Monday that has arrived', () => {
  147. // Due Friday Aug 7, checked Monday evening Aug 10.
  148. const mon = new Date('2026-08-11T01:00:00Z')
  149. expect(reviewGatesMissed('2026-08-08T04:59:59.999Z', mon)).toBe(1)
  150. })
  151. it('can include the occasional Wednesday review', () => {
  152. // Due Friday Jul 24, checked Sat Aug 8: Mondays 27, 3 plus Wednesdays 29, 5.
  153. expect(reviewGatesMissed('2026-07-25T04:59:59.999Z', now, { reviewDays: [1, 3] })).toBe(4)
  154. })
  155. it('defaults to Monday only, since assuming Wednesday would understate lateness', () => {
  156. expect(DEFAULT_REVIEW_DAYS).toEqual([1])
  157. })
  158. it('treats a to-do with no due date as not late', () => {
  159. expect(reviewGatesMissed(null, now)).toBe(0)
  160. expect(isActuallyLate(null, now)).toBe(false)
  161. })
  162. it('reports zero for a future due date', () => {
  163. expect(reviewGatesMissed('2026-09-01T04:59:59.999Z', now)).toBe(0)
  164. })
  165. })
  166. describe('normalizeTodo carries the corrected date through', () => {
  167. const now = new Date('2026-08-09T01:02:45.964Z')
  168. const todos = fixture.map((r) =>
  169. normalizeTodo(RawTodoSchema.parse(r), (id) => UNKNOWN_PERSON(id ?? ''), { now }),
  170. )
  171. it('exposes the local calendar day, not the UTC one', () => {
  172. expect(todos[0]!.dueDate).toBe('2026-08-08T04:59:59.999Z')
  173. expect(todos[0]!.dueDateLocal).toBe('2026-08-07')
  174. expect(todos[1]!.dueDateLocal).toBe('2026-08-14')
  175. })
  176. it('produces a label a person can check against the Ninety UI', () => {
  177. expect(todos[0]!.dueLabel).toBe('Aug 7 (1 day overdue)')
  178. expect(todos[1]!.dueLabel).toBe('Aug 14 (in 6 days)')
  179. })
  180. it('handles a to-do with no due date', () => {
  181. const t = normalizeTodo(RawTodoSchema.parse({ ...fixture[0], dueDate: null }), () => UNKNOWN_PERSON(''), { now })
  182. expect(t.dueDateLocal).toBeNull()
  183. expect(t.dueLabel).toBe('no due date')
  184. })
  185. })