dates.test.ts
Language TypeScript8.5 KB223 lines
/*** Due dates must not shift by a day between the wire and the page.** The expected values below are not invented. They are what the Ninety UI* showed in a screenshot, paired with the exact wire values the API returned for* the same to-dos. If these ever fail, the dashboard is lying about dates again.*/import { describe, expect, it } from 'vitest'import {DEFAULT_REVIEW_DAYS,MERIDIAN_TZ,calendarDaysBetween,dueLabel,isActuallyLate,isOverdue,localDay,localDayBounds,reviewGatesMissed,shortDate,} from '../src/dates.js'import { RawTodoSchema, normalizeTodo, UNKNOWN_PERSON } from '../src/types.js'import fixture from './fixtures/todos.query.json' with { type: 'json' }/*** Ground truth. Left column is what came over the wire, right column is what the* Ninety UI displayed for that same to-do.*/const GROUND_TRUTH = [{ wire: '2026-08-08T04:59:59.999Z', ui: 'Aug 7', day: '2026-08-07' },{ wire: '2026-08-15T04:59:59.999Z', ui: 'Aug 14', day: '2026-08-14' },{ wire: '2026-07-25T04:59:59.999Z', ui: 'Jul 24', day: '2026-07-24' },{ wire: '2026-09-01T04:59:59.999Z', ui: 'Aug 31', day: '2026-08-31' },]describe('localDay: the bug', () => {it.each(GROUND_TRUTH)('$wire renders as $ui, not the UTC day', ({ wire, day }) => {expect(localDay(wire, MERIDIAN_TZ)).toBe(day)})it('proves slicing the ISO string is wrong', () => {// This is what the first version of the smoke test did.expect('2026-08-08T04:59:59.999Z'.slice(0, 10)).toBe('2026-08-08')expect(localDay('2026-08-08T04:59:59.999Z')).toBe('2026-08-07')})it('handles a winter date, where the UTC offset is -6 not -5', () => {// End of day Jan 15 CST is 06:00Z on Jan 16, not 05:00Z. A hardcoded// five-hour subtraction would get this wrong.expect(localDay('2026-01-16T05:59:59.999Z')).toBe('2026-01-15')})it('rejects garbage rather than silently returning a wrong day', () => {expect(() => localDay('not a date')).toThrow(RangeError)})})describe('shortDate matches how the Ninety UI writes dates', () => {it.each(GROUND_TRUTH)('$wire -> $ui', ({ wire, ui }) => {expect(shortDate(wire)).toBe(ui)})})describe('isOverdue uses instant comparison, which was never broken', () => {const now = new Date('2026-08-09T01:02:45.964Z') // a live smoke-test runit('a to-do due end of Aug 7 local is overdue on the evening of Aug 8', () => {expect(isOverdue('2026-08-08T04:59:59.999Z', now)).toBe(true)})it('a to-do due end of Aug 14 local is not yet overdue', () => {expect(isOverdue('2026-08-15T04:59:59.999Z', now)).toBe(false)})it('is not fooled by the last minute of the local day', () => {// 11:58 PM Central on Aug 7: the to-do is due at 11:59:59.999 and is not// overdue yet, even though the UTC date has already rolled over to Aug 8.const justBefore = new Date('2026-08-08T04:58:00.000Z')expect(isOverdue('2026-08-08T04:59:59.999Z', justBefore)).toBe(false)})it('treats a missing due date as not overdue', () => {expect(isOverdue(null, now)).toBe(false)})})describe('calendarDaysBetween counts whole days, ignoring time of day', () => {const now = new Date('2026-08-09T01:02:45.964Z') // evening of Aug 8 Centralit('counts a to-do due end of Aug 7 as one day overdue', () => {expect(calendarDaysBetween(now, '2026-08-08T04:59:59.999Z')).toBe(-1)})it('counts the Jul 24 item as 15 days overdue, matching the smoke test', () => {expect(calendarDaysBetween(now, '2026-07-25T04:59:59.999Z')).toBe(-15)})it('counts forward correctly', () => {expect(calendarDaysBetween(now, '2026-08-15T04:59:59.999Z')).toBe(6)})it('does not round a late-evening due time into an extra day', () => {// 23:59 today is 0 days away, not 1.expect(calendarDaysBetween('2026-08-09T13:00:00Z', '2026-08-10T04:59:59.999Z')).toBe(0)})})describe('dueLabel', () => {const now = new Date('2026-08-09T01:02:45.964Z')it('reads the way the UI does, with a relative hint', () => {expect(dueLabel('2026-08-08T04:59:59.999Z', now)).toBe('Aug 7 (1 day overdue)')expect(dueLabel('2026-07-25T04:59:59.999Z', now)).toBe('Jul 24 (15 days overdue)')expect(dueLabel('2026-08-15T04:59:59.999Z', now)).toBe('Aug 14 (in 6 days)')})it('says today and tomorrow rather than "in 0 days"', () => {expect(dueLabel('2026-08-09T04:59:59.999Z', now)).toBe('Aug 8 (due today)')expect(dueLabel('2026-08-10T04:59:59.999Z', now)).toBe('Aug 9 (due tomorrow)')})it('handles no due date', () => {expect(dueLabel(null, now)).toBe('no due date')})})describe('localDayBounds, for "what changed since yesterday"', () => {it('spans a full local day during CDT', () => {const { startMs, endMs } = localDayBounds('2026-08-07')expect(new Date(startMs).toISOString()).toBe('2026-08-07T05:00:00.000Z')expect(endMs - startMs).toBe(86_400_000)})it('spans a full local day during CST', () => {const { startMs } = localDayBounds('2026-01-15')expect(new Date(startMs).toISOString()).toBe('2026-01-15T06:00:00.000Z')})it('contains a to-do due at end of that local day', () => {const { startMs, endMs } = localDayBounds('2026-08-07')const due = Date.parse('2026-08-08T04:59:59.999Z')expect(due).toBeGreaterThanOrEqual(startMs)expect(due).toBeLessThan(endMs)})})describe('reviewGatesMissed: Meridian closes to-dos only on Mondays', () => {// Saturday evening Aug 8, the moment the real smoke test ran.const now = new Date('2026-08-09T01:02:45.964Z')it('a to-do due Friday Aug 7 has missed zero gates on Saturday', () => {// Next review is Monday Aug 10. Overdue on paper, on time in practice.expect(reviewGatesMissed('2026-08-08T04:59:59.999Z', now)).toBe(0)expect(isOverdue('2026-08-08T04:59:59.999Z', now)).toBe(true)expect(isActuallyLate('2026-08-08T04:59:59.999Z', now)).toBe(false)})it('the Jul 24 item has missed two gates and is genuinely late', () => {// Mondays Jul 27 and Aug 3 both passed without it being closed.expect(reviewGatesMissed('2026-07-25T04:59:59.999Z', now)).toBe(2)expect(isActuallyLate('2026-07-25T04:59:59.999Z', now)).toBe(true)})it('turns "2 overdue" into "1 actually late" across the real open items', () => {const dues = ['2026-07-25T04:59:59.999Z', '2026-08-08T04:59:59.999Z', '2026-09-01T04:59:59.999Z']expect(dues.filter((d) => isOverdue(d, now))).toHaveLength(2)expect(dues.filter((d) => isActuallyLate(d, now))).toHaveLength(1)})it('does not count the review day an item is due on', () => {// Due Monday Aug 10, checked Tuesday Aug 11: the next chance is Aug 17.const tue = new Date('2026-08-12T01:00:00Z')expect(reviewGatesMissed('2026-08-11T04:59:59.999Z', tue)).toBe(0)})it('counts a Monday that has arrived', () => {// Due Friday Aug 7, checked Monday evening Aug 10.const mon = new Date('2026-08-11T01:00:00Z')expect(reviewGatesMissed('2026-08-08T04:59:59.999Z', mon)).toBe(1)})it('can include the occasional Wednesday review', () => {// Due Friday Jul 24, checked Sat Aug 8: Mondays 27, 3 plus Wednesdays 29, 5.expect(reviewGatesMissed('2026-07-25T04:59:59.999Z', now, { reviewDays: [1, 3] })).toBe(4)})it('defaults to Monday only, since assuming Wednesday would understate lateness', () => {expect(DEFAULT_REVIEW_DAYS).toEqual([1])})it('treats a to-do with no due date as not late', () => {expect(reviewGatesMissed(null, now)).toBe(0)expect(isActuallyLate(null, now)).toBe(false)})it('reports zero for a future due date', () => {expect(reviewGatesMissed('2026-09-01T04:59:59.999Z', now)).toBe(0)})})describe('normalizeTodo carries the corrected date through', () => {const now = new Date('2026-08-09T01:02:45.964Z')const todos = fixture.map((r) =>normalizeTodo(RawTodoSchema.parse(r), (id) => UNKNOWN_PERSON(id ?? ''), { now }),)it('exposes the local calendar day, not the UTC one', () => {expect(todos[0]!.dueDate).toBe('2026-08-08T04:59:59.999Z')expect(todos[0]!.dueDateLocal).toBe('2026-08-07')expect(todos[1]!.dueDateLocal).toBe('2026-08-14')})it('produces a label a person can check against the Ninety UI', () => {expect(todos[0]!.dueLabel).toBe('Aug 7 (1 day overdue)')expect(todos[1]!.dueLabel).toBe('Aug 14 (in 6 days)')})it('handles a to-do with no due date', () => {const t = normalizeTodo(RawTodoSchema.parse({ ...fixture[0], dueDate: null }), () => UNKNOWN_PERSON(''), { now })expect(t.dueDateLocal).toBeNull()expect(t.dueLabel).toBe('no due date')})})