stacksjs

stacks-datetime

Use when working with dates and times in Stacks - the DateTime class with Carbon-like API (add/sub, comparison, formatting, start/end of day/month/year), date parsing, format tokens, timezone handling, wall-clock times in a named zone across DST, or YYYY-MM-DD calendar dates (today in a zone, day arithmetic, ISO weeks). Covers @stacksjs/datetime.

stacksjs 636 20 Updated 5d ago
GitHub

Install

npx skillscat add stacksjs/stacks/stacks-datetime

Install via the SkillsCat registry.

SKILL.md

Stacks DateTime

Carbon-inspired DateTime class with immutable operations and zero external dependencies.

Key Paths

  • Core package: storage/framework/core/datetime/src/
  • Package: @stacksjs/datetime

Architecture

The index.ts exports:

export { DateTime, now } from './now'
export { format } from './format'
export { parse } from './parse'
export { format as dateFormat } from './format'  // convenience alias

Source files:

  • now.ts - DateTime class and now() helper
  • format.ts - standalone format() function with token-based formatting and timezone support
  • parse.ts - standalone parse() function with token-based parsing
  • zone.ts - wall clock in a named zone to an instant and back (zonedTimeToUtc, isoInZone, zoneOffsetMinutes)
  • calendar.ts - addCalendarMonths and the YYYY-MM-DD helpers (todayIn, addDays, daysBetween, startOfIsoWeek, isCalendarDate, isTimeZone, minutesUntil)
  • local.ts - localDateTime (site and organization clocks)
  • schedule.ts - recurrences, coverage and local reporting periods

DateTime Class (now.ts)

Construction

import { DateTime, now } from '@stacksjs/datetime'

const dt = new DateTime()                          // current time
const dt = new DateTime(new Date())                // from native Date (cloned)
const dt = new DateTime('2024-06-15')              // from date string
const dt = new DateTime('15/06/2024', 'DD/MM/YYYY') // from string with format

// Static factory methods
const dt = DateTime.now()                          // current time
const dt = DateTime.create(2024, 6, 15, 10, 30, 0) // year, month (1-12), day, hour, min, sec
const dt = DateTime.fromDate(new Date())           // from native Date
const dt = DateTime.parse('2024-06-15')            // from string
const dt = DateTime.parse('15/06/2024', 'DD/MM/YYYY') // from string with format

Note: DateTime.create() uses 1-based months (1=January), converting internally with month - 1.

Getters (read-only properties)

dt.year        // number -- full year (e.g. 2024)
dt.month       // number -- 1-12 (1-based, NOT zero-based like native Date)
dt.day         // number -- day of month (1-31)
dt.hour        // number -- 0-23
dt.minute      // number -- 0-59
dt.second      // number -- 0-59
dt.dayOfWeek   // number -- 0 (Sunday) through 6 (Saturday)
dt.timestamp   // number -- milliseconds since Unix epoch

Formatting

dt.format('YYYY-MM-DD HH:mm:ss')           // '2024-06-15 10:30:00'
dt.format('MMMM D, YYYY', 'en')            // 'June 15, 2024'
dt.format('HH:mm', { locale: 'de', tz: 'Europe/Berlin' })

dt.toDateString()                            // 'YYYY-MM-DD' format
dt.toTimeString()                            // 'HH:mm:ss' format
dt.toDateTimeString()                        // 'YYYY-MM-DD HH:mm:ss' format
dt.toISOString()                             // ISO 8601 (native Date.toISOString)
dt.toString()                                // same as toDateTimeString()
dt.toJSON()                                  // same as toISOString()

Format Tokens

Used by both DateTime.format() and the standalone format() function:

Token Output Description
YYYY 2024 4-digit year
YY 24 2-digit year
MMMM January Full month name (via Intl.DateTimeFormat)
MMM Jan Short month name (via Intl.DateTimeFormat)
MM 01 2-digit month (01-12)
M 1 Month (1-12)
DD 01 2-digit day (01-31)
D 1 Day (1-31)
dddd Wednesday Full weekday name (via Intl.DateTimeFormat)
ddd Wed Short weekday name (via Intl.DateTimeFormat)
d W Narrow weekday (via Intl.DateTimeFormat)
HH 00 24-hour padded (00-23)
H 0 24-hour (0-23)
hh 12 12-hour padded (01-12)
h 12 12-hour (1-12)
mm 05 Minutes padded (00-59)
m 5 Minutes (0-59)
ss 09 Seconds padded (00-59)
s 9 Seconds (0-59)
A AM/PM Uppercase AM/PM
a am/pm Lowercase am/pm
Z +0530 Timezone offset (no colon, e.g. +0800, -0500)

Tokens are matched longest-first to avoid partial matching.

Arithmetic (all return NEW DateTime instances -- immutable)

dt.addSeconds(30)      dt.subSeconds(30)
dt.addMinutes(15)      dt.subMinutes(15)
dt.addHours(2)         dt.subHours(2)
dt.addDays(7)          dt.subDays(7)
dt.addWeeks(1)         dt.subWeeks(1)    // delegates to addDays(n * 7)
dt.addMonths(3)        dt.subMonths(3)
dt.addYears(1)         dt.subYears(1)

Implementation details:

  • addSeconds/Minutes/Hours use millisecond arithmetic on timestamp
  • addDays uses Date.setDate() (handles month boundaries correctly)
  • addMonths uses Date.setMonth() (handles year boundaries)
  • Month/year arithmetic inherits native overflow behavior. For a business
    calendar month that must clamp January 31 to February's last day, use the
    exported addCalendarMonths(date, count, { utc? }) helper instead.
  • addYears uses Date.setFullYear()
  • All sub* methods delegate to add*(-n)

Period Boundaries (return NEW DateTime instances)

dt.startOfDay()     // sets time to 00:00:00.000
dt.endOfDay()       // sets time to 23:59:59.999

dt.startOfMonth()   // sets to 1st of month, 00:00:00.000
dt.endOfMonth()     // sets to last day of month, 23:59:59.999
                    // uses new Date(year, month + 1, 0) trick for last day

dt.startOfYear()    // January 1, 00:00:00.000
dt.endOfYear()      // December 31, 23:59:59.999

Setters (return NEW DateTime instances)

dt.setYear(2025)     // new DateTime with year changed
dt.setMonth(6)       // 1-based month (internally does month - 1)
dt.setDay(15)        // uses Date.setDate()
dt.setHour(10)
dt.setMinute(30)
dt.setSecond(0)

Comparison

dt.isBefore(other)         // true if this < other (millisecond comparison)
dt.isAfter(other)          // true if this > other
dt.isSame(other)           // true if exact same millisecond
dt.isSameDay(other)        // true if same year, month, day (ignores time)
dt.isBetween(start, end)   // true if this > start AND this < end (exclusive)
dt.isPast()                // true if before Date.now()
dt.isFuture()              // true if after Date.now()
dt.isToday()               // true if same day as today (uses isSameDay)
dt.isLeapYear()            // true if year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)

All comparison methods accept DateTime | Date as the other parameter.

Difference

dt.diffInSeconds(other)    // number -- Math.floor((this - other) / 1000)
dt.diffInMinutes(other)    // number -- Math.floor(diffInSeconds / 60)
dt.diffInHours(other)      // number -- Math.floor(diffInSeconds / 3600)
dt.diffInDays(other)       // number -- Math.floor(diffInSeconds / 86400)

Returns signed values: positive if this is after other, negative if before.

Conversion

dt.toNativeDate()          // new Date (cloned)
dt.valueOf()               // number (milliseconds since epoch)
dt.toJSON()                // ISO string (for JSON serialization)

Standalone format() Function (format.ts)

import { format } from '@stacksjs/datetime'

format(new Date(), 'YYYY-MM-DD')                    // '2024-06-15'
format('2024-06-15', 'MMMM D, YYYY')                // 'June 15, 2024'
format(new Date(), 'dddd, MMMM D, YYYY h:mm A')     // 'Saturday, June 15, 2024 10:30 AM'

// With locale
format(new Date(), 'dddd, MMMM D', 'de')            // 'Samstag, Juni 15'

// With timezone
format(new Date(), 'YYYY-MM-DD HH:mm Z', { tz: 'America/New_York' })
format(new Date(), 'HH:mm', { locale: 'en', tz: 'Asia/Tokyo' })

Signature: format(inputDate: Date | string, formatStr?: string, localeOrOptions?: string | { locale?: string, tz?: string })

Default format string: 'YYYY-MM-DD'
Default locale: 'en'

Timezone Support

When a tz option is provided, the function uses Intl.DateTimeFormat with timeZone to extract date parts in the target timezone. The timezone offset (Z token) is computed by comparing UTC wall-clock time to the timezone's wall-clock time.

Without tz, uses local time via native Date getters (faster path, no Intl overhead for numeric parts). Named parts (month names, weekday names) always use Intl for locale support.

Standalone parse() Function (parse.ts)

import { parse } from '@stacksjs/datetime'

// Without format string -- native Date parsing with fix
parse('2024-06-15')                    // Date -- treated as LOCAL time (not UTC)
parse('2024-06-15T10:30:00Z')          // Date -- ISO string, native parsing

// With format string -- token-based extraction
parse('15/06/2024', 'DD/MM/YYYY')      // Date
parse('June 15, 2024', 'MMMM DD, YYYY') // Date
parse('2024-06-15 10:30:00', 'YYYY-MM-DD HH:mm:ss')  // Date
parse('03:30 PM', 'hh:mm A')          // Date with AM/PM handling
parse('2024-06-15 10:30 +0530', 'YYYY-MM-DD HH:mm Z') // Date with timezone offset

Parse Behavior

Without format string:

  • Date-only ISO strings (YYYY-MM-DD) are parsed as LOCAL time, not UTC -- this is a deliberate fix for the native Date behavior where new Date('2024-06-15') treats it as UTC causing day shifts
  • All other strings use native new Date() parsing
  • Rejects unparseable native dates. Calendar fields can normalize through native
    Date, so use validation for strict rejection of impossible dates such as
    February 31 rather than treating this parser as a validation boundary.

With format string:

  • Builds a regex from the format tokens and extracts named groups
  • Supports tokens: YYYY, YY, MMMM, MMM, MM, M, DD, D, HH, H, hh, h, mm, m, ss, s, A, a, Z
  • YY: years 70-99 become 1900s, 00-69 become 2000s
  • AM/PM: adjusts hours for 12-hour format (12 AM = 0, 12 PM = 12)
  • Month names: handles full ("January") and short ("Jan") names, case-insensitive
  • Z token: parses +HHMM offset, constructs UTC time and adjusts
  • Throws if the format doesn't match the input string

Named-zone instants and calendar months

The root additionally exports zoneOffsetMinutes(instant, timeZone),
zonedTimeToUtc(wallClock, timeZone), isoInZone(instant, timeZone) and
addCalendarMonths(date, count, { utc? }). A wall-clock string has no supplied
offset; the named IANA zone converts it into an instant. isoInZone retains
that zone's offset in serialized output rather than merely displaying a date.
addCalendarMonths clones and clamps the day; ordinary DateTime.addMonths
preserves native Date overflow instead. Do not interchange those contracts.

zonedTimeToUtc resolves DST edges the same way in every zone (RFC 5545 and
Temporal's 'compatible' rule):

Wall clock Zone Result Why
2026-10-25T02:30 Europe/Berlin 2026-10-25T00:30:00Z (+02:00) Repeated hour: the EARLIER instant
2026-11-01T01:30 America/New_York 2026-11-01T05:30:00Z (-04:00) Repeated hour: the EARLIER instant
2026-03-29T02:30 Europe/Berlin 2026-03-29T01:30:00Z, shown as 03:30+02:00 Skipped: read with the offset before the gap
2026-03-08T02:30 America/New_York 2026-03-08T07:30:00Z, shown as 03:30-04:00 Skipped: read with the offset before the gap

Releases up to 0.75.228 returned the LATER instant of a repeated hour in zones
at or east of UTC (Berlin, London, Sydney) while New York got the earlier one.

import { zonedTimeToUtc } from '@stacksjs/datetime'

zonedTimeToUtc('2026-10-01T20:00:00', 'America/New_York').toISOString() // '2026-10-02T00:00:00.000Z'
zonedTimeToUtc('2026-10-25T02:30', 'Europe/Berlin').toISOString()       // '2026-10-25T00:30:00.000Z'

Calendar dates (YYYY-MM-DD strings)

A date column, a training day or a membership period is a day on a calendar,
not an instant. Keep it a YYYY-MM-DD string: these helpers do their arithmetic
on the UTC calendar (every day 24 hours, whatever the server zone) and only meet
a time zone where a real clock is involved (todayIn, minutesUntil). Source:
src/calendar.ts.

import { addDays, daysBetween, isCalendarDate, isTimeZone, minutesUntil, startOfIsoWeek, todayIn } from '@stacksjs/datetime'

isCalendarDate('2026-02-31')          // false (a type guard; also rejects '2026-2-3')
isTimeZone('Europe/Berlin')           // true  (a type guard; '' and unknown zones are false)
todayIn('Pacific/Auckland', now)      // the date there, e.g. '2026-10-19' while UTC is on the 18th
addDays('2026-12-31', 1)              // '2027-01-01'
daysBetween('2026-10-18', '2026-10-25') // 7 (signed: negative when the second is earlier)
startOfIsoWeek('2026-10-18')          // '2026-10-12' (the Monday; a Sunday belongs to the week before)

// Real minutes from now until 09:00 on a date in a zone, correct across DST:
minutesUntil('2026-10-25', '09:00', 'Europe/Berlin', new Date('2026-10-24T22:00:00Z')) // 600, not 540
  • Every helper throws RangeError for an impossible date, a malformed clock
    time (HH:mm or HH:mm:ss, 00-23 hours), an unknown zone or an invalid
    instant. Validate input with isCalendarDate / isTimeZone first.
  • todayIn takes one zone. To fall back (a site's zone, then its
    organization's, then UTC) use localDateTime(now, ...zones).date.
  • minutesUntil keeps the seconds of now as a fraction; round where you
    display or compare. Negative once the moment has passed.
  • addDays refuses a result past 9999-12-31.

Reading DATE columns: parseSqlDate (@stacksjs/database)

Drivers return a DATE column as a JS Date, at UTC midnight (Bun's Postgres
driver) or at the host's local midnight (others). date.toISOString().slice(0, 10)
is only right for the first: on a host east of UTC a local-midnight Date moves
to the day before. parseSqlDate(value) from @stacksjs/database returns the
stored YYYY-MM-DD either way (local midnight reads local fields, anything else
UTC fields), keeps the date part of strings, and returns null for missing,
invalid or impossible input. It sits beside parseSqlDateTime.

Current-time helper example

import { now } from '@stacksjs/datetime'

now()                                  // DateTime.now()
now().toDateString()                   // '2024-06-15'
now().format('MMMM D, YYYY')          // 'June 15, 2024'
now().addDays(7).toDateString()        // one week from now
now().startOfMonth().toDateString()    // first of current month

Gotchas

  • All DateTime arithmetic operations return NEW instances -- the original is never mutated
  • DateTime.month returns 1-12 (1-based), NOT 0-11 like native Date
  • DateTime.create(year, month) takes 1-based month -- internally converts with month - 1
  • dayOfWeek returns 0 (Sunday) through 6 (Saturday) -- matches native Date.getDay()
  • timestamp returns MILLISECONDS since epoch, not seconds
  • diffIn* methods return signed values -- positive when this is after other
  • isBetween(start, end) is EXCLUSIVE on both ends (strictly between)
  • parse('2024-06-15') without format is treated as LOCAL time (intentional deviation from spec)
  • The Z timezone offset token format is +HHMM (no colon) -- e.g. +0530, -0800
  • format() with tz option uses Intl.DateTimeFormat which requires valid IANA timezone names
  • The d token in formatting returns the NARROW weekday (single letter like "W"), not a day number
  • endOfMonth() uses the new Date(year, month + 1, 0) trick to find the last day
  • Application timezone should be configured in config/app.ts
  • Prefer @stacksjs/datetime over raw Date for framework consistency

Site and organization clocks

localDateTime(instant, ...timeZones) resolves the first valid named zone, then
UTC, and returns { date, time, timeZone } from one instant. Supply site and
organization zones in that order. Missing/invalid zones fall through; an
invalid instant throws. The output uses YYYY-MM-DD and HH:mm and is suitable
for comparing local schedule dates and end times, not for elapsed durations.

Recurrences, coverage and local reporting periods

The pure schedule entry exports parseWeeklySchedule, expandWeeklySchedule,
clockMinutes, addClockMinutes and timeWindowsCover. Recurrence validation
rejects invalid weekday entries; expansion refuses excessive ranges and
midnight-crossing durations instead of silently truncating them. Adjacent
windows can cover one continuous slot. ScheduleInputError carries status 422.

localDateRange(from, to, ...zones) converts inclusive calendar days to an
exclusive-end UTC instant range in a selected valid zone, including DST days.
Its supported reporting dates are 0101-01-01 through 9999-12-30; unsupported
boundaries raise ScheduleInputError rather than normalizing to another year.
netHoursBetween(start, end, breaks) retains full precision; apply display or
monetary rounding at the final boundary. It performs duration arithmetic,
not jurisdiction-specific compensation or overtime decisions.

localTimeSpan(date, start, end, ...zones) converts a clock interval, including
next-day ends, to actual instants for cross-site/overnight overlap checks.
Equal endpoints and non-positive DST-normalized intervals are rejected.