mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
feat: add date-key module with local-time formatting
Switches task keys to ISO YYYY-MM-DD. They sort, so finding every day before today is a string compare instead of re-parsing each key. Two things worth knowing. fromKey builds from date parts rather than parsing a bare ISO string, which gets read as UTC and lands on the wrong day west of Greenwich. addDays goes through setDate, so it stays on the same calendar day across a DST change instead of adding a flat 24 hours.
This commit is contained in:
@@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user