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.
Install
npx skillscat add stacksjs/stacks/stacks-datetime Install via the SkillsCat registry.
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 aliasSource files:
now.ts-DateTimeclass andnow()helperformat.ts- standaloneformat()function with token-based formatting and timezone supportparse.ts- standaloneparse()function with token-based parsingzone.ts- wall clock in a named zone to an instant and back (zonedTimeToUtc,isoInZone,zoneOffsetMinutes)calendar.ts-addCalendarMonthsand theYYYY-MM-DDhelpers (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 formatNote: 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 epochFormatting
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/Hoursuse millisecond arithmetic on timestampaddDaysusesDate.setDate()(handles month boundaries correctly)addMonthsusesDate.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
exportedaddCalendarMonths(date, count, { utc? })helper instead. addYearsusesDate.setFullYear()- All
sub*methods delegate toadd*(-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.999Setters (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 offsetParse 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) andaddCalendarMonths(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
RangeErrorfor an impossible date, a malformed clock
time (HH:mmorHH:mm:ss, 00-23 hours), an unknown zone or an invalid
instant. Validate input withisCalendarDate/isTimeZonefirst. todayIntakes one zone. To fall back (a site's zone, then its
organization's, then UTC) uselocalDateTime(now, ...zones).date.minutesUntilkeeps the seconds ofnowas a fraction; round where you
display or compare. Negative once the moment has passed.addDaysrefuses 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 monthGotchas
- All DateTime arithmetic operations return NEW instances -- the original is never mutated
DateTime.monthreturns 1-12 (1-based), NOT 0-11 like native DateDateTime.create(year, month)takes 1-based month -- internally converts withmonth - 1dayOfWeekreturns 0 (Sunday) through 6 (Saturday) -- matches native Date.getDay()timestampreturns MILLISECONDS since epoch, not secondsdiffIn*methods return signed values -- positive whenthisis afterotherisBetween(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
Ztimezone offset token format is+HHMM(no colon) -- e.g.+0530,-0800 format()withtzoption usesIntl.DateTimeFormatwhich requires valid IANA timezone names- The
dtoken in formatting returns the NARROW weekday (single letter like "W"), not a day number endOfMonth()uses thenew Date(year, month + 1, 0)trick to find the last day- Application timezone should be configured in
config/app.ts - Prefer
@stacksjs/datetimeover rawDatefor 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.