timezone.test.ts
Language TypeScript7.0 KB159 lines
/*** No reader of this board is in UTC.** Times rendered in UTC read as the wrong hour, and after 7 PM as the wrong* day; a raw ISO string on the page is worse. The correct helpers live in* `packages/ninety-client/src/dates.ts`. Using them is not the hard part;* noticing a site that does not is. So this asserts the property over whole* rendered documents rather than checking sites one at a time. A new* surface that slices an ISO string fails here without anyone remembering to* come back and add a case.*/import { describe, expect, it } from 'vitest'import { localClock, localDay, localStamp, MERIDIAN_TZ } from '@meridian/ninety-client'import { render } from '../src/render.js'import { headline } from '../src/collect.js'import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'import { inboxPage, messagePage, sentPage, type InboxData, type SendRecord } from '../src/inbox-view.js'import { INBOX_MAILBOXES } from '../../../config/inbox.js'import { viewerFor } from '../src/viewer.js'import type { InboxMessage, MessageBody } from '@meridian/gmail-client'/** An instant deliberately chosen to land on a different calendar day in UTC. */const EVENING = '2026-09-05T02:30:00.000Z' // 9:30 PM CT on Sep 4describe('localStamp / localClock', () => {it('renders an instant in Central, not UTC', () => {// 02:30 UTC on the 5th is 9:30 PM on the 4th in Chicago. A slice of the ISO// string gets both the day and the hour wrong.expect(localStamp(EVENING)).toContain('Sep 4')expect(localStamp(EVENING)).toContain('9:30')expect(localClock(EVENING)).toBe('9:30 PM')expect(localDay(EVENING)).toBe('2026-09-04')})it('accepts epoch milliseconds, which is what the replied marks store', () => {expect(localStamp(Date.parse(EVENING))).toBe(localStamp(EVENING))})it('renders an absence rather than "Invalid Date"', () => {// Callers render whatever the board stored, and stored values go missing.for (const bad of ['', 'not a date', 'undefined']) {expect(localStamp(bad)).toBe('–')expect(localClock(bad)).toBe('–')}})it('follows the DST change without a code edit', () => {// The offset changes twice a year. Intl handles it; the pages say "CT"// rather than CDT/CST so nothing needs editing.const summer = localClock('2026-07-01T17:00:00.000Z') // CDT, UTC-5const winter = localClock('2026-12-01T17:00:00.000Z') // CST, UTC-6expect(summer).toBe('12:00 PM')expect(winter).toBe('11:00 AM')expect(MERIDIAN_TZ).toBe('America/Chicago')})})/** Every ISO instant that should never survive to a rendered page. */const RAW_ISO = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}//*** Strip everything that is not text a reader sees.** Script and style BODIES go too. A code comment explaining the UTC bug is not* the UTC bug. That would be a hole if an inlined script formatted a date* wrongly, so the next test closes it directly rather than leaving it to this* one.*/const scrub = (html: string): string =>html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '').replace(/<style\b[^>]*>[\s\S]*?<\/style>/g, '')// datetime="" and machine-readable attributes are allowed to be ISO..replace(/datetime="[^"]*"/g, '')// URLs may legitimately carry timestamps..replace(/href="[^"]*"/g, '').replace(/src="[^"]*"/g, '')/** The script bodies on their own, which scrub() removes. */const scriptsOf = (html: string): string =>(html.match(/<script\b[^>]*>[\s\S]*?<\/script>/g) ?? []).join('\n')describe('no rendered page shows a reader a UTC time', () => {const viewer = viewerFor('hello@meridiandisplay.example')it('the board carries no raw ISO timestamp and no "UTC" label', () => {const html = render(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12', null, viewer)const body = scrub(html)expect(body).not.toMatch(RAW_ISO)// The one that shipped: a meeting-commitment row printing the raw instant.expect(body).not.toContain('.670Z')expect(body).not.toMatch(/\bUTC\b/)})it('every date an inlined script formats passes a timeZone', () => {// scrub() drops script bodies, so this is where that hole is closed. The// notes bootstrap runs in the browser and cannot call localDay(); the// timeZone option is the same fix, and without it a note typed at 8 PM// showed tomorrow's date to the person who had just typed it.const js = scriptsOf(render(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12', null, viewer),)for (const call of js.match(/toLocale\w*\([^)]*\)/g) ?? []) {expect(call, `${call} formats a date with no timeZone`).toContain('timeZone')}// And nothing in a script may slice an ISO string into a displayed date.expect(js).not.toMatch(/\.at[^\n]*\.slice\(0,\s*10\)/)})it('the inbox list carries neither', () => {const data: InboxData = {syncedAt: EVENING,mailboxes: [{mailbox: 'hello@meridiandisplay.example', ok: true, error: null, canSend: true,messages: [{id: 'm1', threadId: 't1', mailbox: 'hello@meridiandisplay.example',sender: 'client@example.com', senderName: 'A Client',subject: 'Proof', snippet: 'hi', date: EVENING,labels: ['INBOX'], unread: true,} satisfies InboxMessage],}],}const html = scrub(inboxPage(data, [], INBOX_MAILBOXES, ['hello@meridiandisplay.example'], 'Sam Whitaker'))expect(html).not.toMatch(RAW_ISO)expect(html).not.toMatch(/\bUTC\b/)})it('an open message dates itself in Central', () => {const body: MessageBody = {id: 'm1', mailbox: 'hello@meridiandisplay.example', sender: 'client@example.com',senderName: 'A Client', subject: 'Proof', date: EVENING,to: 'hello@meridiandisplay.example', text: 'hello', kind: 'plain', truncated: false,html: null, htmlTruncated: false, unread: true, attachments: [],threadId: 't1', messageId: null, references: null, replyTo: '', cc: '',}const html = scrub(messagePage(body, 'Sam Whitaker'))expect(html).not.toMatch(RAW_ISO)expect(html).not.toMatch(/\bUTC\b/)expect(html).toContain('Sep 4')expect(html).toContain('CT')})it('the sent record dates itself in Central: Sam’s "email delivery times"', () => {const rec: SendRecord = {nonce: 'n1', at: EVENING, by: 'Sam Whitaker', byEmail: 'hello@meridiandisplay.example',from: 'hello@meridiandisplay.example', to: 'client@example.com', subject: 'Re: Proof',originalMailbox: 'hello@meridiandisplay.example', originalId: 'm1', originalMessageId: null,threadIdSent: null, signatureApplied: false, outcome: 'sent',gmailId: 'g1', gmailThreadId: 'gt1', error: null, candidates: [],}const html = scrub(sentPage([rec], false, rec, false))expect(html).not.toMatch(RAW_ISO)expect(html).not.toMatch(/\bUTC\b/)// The day column was a UTC calendar day, so an evening send showed tomorrow.expect(html).toContain('2026-09-04')expect(html).not.toContain('2026-09-05')})})