diff --git a/src/lib/dates.js b/src/lib/dates.js new file mode 100644 index 0000000..ecb561f --- /dev/null +++ b/src/lib/dates.js @@ -0,0 +1,44 @@ +const KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +/** A `Date` as a local-time `YYYY-MM-DD` key. Sortable, which is what the + * rollover logic relies on to find every day earlier than today. */ +export function toKey(date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** Local midnight for a key. Built from parts rather than `new Date(key)`, + * which parses bare ISO dates as UTC and shifts the day west of Greenwich. */ +export function fromKey(key) { + const [year, month, day] = key.split('-').map(Number) + return new Date(year, month - 1, day) +} + +/** Goes through `setDate`, so it stays on the same calendar day across a DST + * boundary rather than adding a fixed 24 hours. */ +export function addDays(date, amount) { + const next = new Date(date.getTime()) + next.setDate(next.getDate() + amount) + return next +} + +export function isKey(value) { + return KEY_PATTERN.test(value) +} + +export function labelFor(dateKey, todayKey) { + if (dateKey === todayKey) return 'Today' + if (dateKey === toKey(addDays(fromKey(todayKey), 1))) return 'Tomorrow' + return fromKey(dateKey).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +export function formatLong(dateKey) { + return fromKey(dateKey).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) +} diff --git a/src/lib/dates.test.js b/src/lib/dates.test.js new file mode 100644 index 0000000..47ee4b7 --- /dev/null +++ b/src/lib/dates.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { toKey, fromKey, addDays, isKey, labelFor, formatLong } from './dates.js' + +describe('toKey', () => { + it('formats a date as local YYYY-MM-DD', () => { + expect(toKey(new Date(2026, 7, 15))).toBe('2026-08-15') + }) + + it('zero-pads single-digit months and days', () => { + expect(toKey(new Date(2026, 0, 5))).toBe('2026-01-05') + }) + + it('uses local time, not UTC', () => { + // 23:30 local on the 15th must not roll forward to the 16th. + expect(toKey(new Date(2026, 7, 15, 23, 30))).toBe('2026-08-15') + }) +}) + +describe('fromKey', () => { + it('returns local midnight for the key', () => { + const date = fromKey('2026-08-15') + expect(date.getFullYear()).toBe(2026) + expect(date.getMonth()).toBe(7) + expect(date.getDate()).toBe(15) + expect(date.getHours()).toBe(0) + }) + + it('round-trips with toKey', () => { + expect(toKey(fromKey('2026-08-15'))).toBe('2026-08-15') + }) +}) + +describe('addDays', () => { + it('advances across a month boundary', () => { + expect(toKey(addDays(new Date(2026, 7, 31), 1))).toBe('2026-09-01') + }) + + it('advances across a year boundary', () => { + expect(toKey(addDays(new Date(2026, 11, 31), 1))).toBe('2027-01-01') + }) + + it('does not mutate its argument', () => { + const date = new Date(2026, 7, 15) + addDays(date, 5) + expect(toKey(date)).toBe('2026-08-15') + }) +}) + +describe('isKey', () => { + it('accepts ISO date keys', () => { + expect(isKey('2026-08-15')).toBe(true) + }) + + it('rejects legacy toDateString keys', () => { + expect(isKey('Sat Aug 15 2026')).toBe(false) + }) + + it('rejects arbitrary strings', () => { + expect(isKey('not-a-date')).toBe(false) + }) +}) + +describe('labelFor', () => { + it('labels today', () => { + expect(labelFor('2026-08-15', '2026-08-15')).toBe('Today') + }) + + it('labels tomorrow', () => { + expect(labelFor('2026-08-16', '2026-08-15')).toBe('Tomorrow') + }) + + it('labels tomorrow across a month boundary', () => { + expect(labelFor('2026-09-01', '2026-08-31')).toBe('Tomorrow') + }) + + it('labels tomorrow across a year boundary', () => { + expect(labelFor('2027-01-01', '2026-12-31')).toBe('Tomorrow') + }) + + it('falls back to a short date for other days', () => { + expect(labelFor('2026-08-20', '2026-08-15')).toBe('Aug 20') + }) +}) + +describe('formatLong', () => { + it('renders the long-form date used in the header', () => { + expect(formatLong('2026-08-15')).toBe('Saturday, August 15, 2026') + }) +})