Skip to content

Add zoned date time - #201

Merged
marcospassos merged 19 commits into
masterfrom
add-zoned-date-time
Aug 14, 2026
Merged

Add zoned date time#201
marcospassos merged 19 commits into
masterfrom
add-zoned-date-time

Conversation

@marcospassos

@marcospassos marcospassos commented Aug 12, 2026

Copy link
Copy Markdown
Member

Adds ZonedDateTime, along with ZoneOffset, ZoneRules and ZoneOffsetTransition.

Offsets for region-based zones come from Intl, so there is no time zone database to bundle or keep up to date. Gaps and overlaps are found by probing the offset around a point in time.

TimeZone becomes an abstract base that resolves to either a region or a fixed offset. TimeZone.of keeps its signature, so existing code is unaffected.

Also included:

  • with methods on LocalDate, LocalTime, LocalDateTime and ZonedDateTime. withYear and withMonth move an invalid day back to the last valid one, withDay rejects it.
  • atZone on Instant and LocalDateTime.
  • Coverage is now 100% and enforced in jest.config.js. Reaching it covered LocalDate.now, LocalTime.now, PeriodDuration.isZero and Weekday, which had no tests.

Add ZonedDateTime, ZoneOffset, ZoneRules and ZoneOffsetTransition.

Offsets for region-based zones come from Intl instead of a bundled time
zone database. Rebuild both LocalDateTime conversions on top of it.
@marcospassos
marcospassos requested review from a team as code owners August 12, 2026 15:54
@github-actions

Copy link
Copy Markdown

👋 @marcospassos

Thanks for your contribution!

The approval and merge process is almost fully automated 🧙

Here's how it works:

  1. You open a new pull request
  2. Automated tests check the code
  3. Maintainers review the code
  4. Once approved, the PR is ready to merge.

👉 Omit the extended description
Please remove the commit body before merging the pull request.
Instead, include the pull request number in the title to provide the full context
about the change.

☝️ Lastly, the title for the commit will come from the pull request title. So please provide a descriptive title that summarizes the changes in 50 characters or less using the imperative mood.

Happy coding! 🎉

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces first-class time-zone and transition support by adding ZonedDateTime plus supporting types (ZoneOffset, ZoneRules, ZoneOffsetTransition), and refactors existing date/time types to integrate with these new abstractions while expanding test coverage and enforcing 100% Jest coverage.

Changes:

  • Add region-based and fixed-offset time-zone rules via ZoneRules (derived from Intl) and transitions via ZoneOffsetTransition.
  • Add ZonedDateTime plus new atZone/with* APIs across Instant, LocalDateTime, LocalDate, and LocalTime.
  • Expand/adjust tests and enforce 100% global coverage in Jest; document time-zone limitations in the README.

Reviewed changes

Copilot reviewed 20 out of 21 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/zoneRules.test.ts Adds tests for ZoneRules behavior (offsets, transitions, gaps/overlaps, resolution).
test/zoneOffsetTransition.test.ts Adds tests for ZoneOffsetTransition value semantics and behavior.
test/zoneOffset.test.ts Adds tests for ZoneOffset parsing, range validation, comparison, and serialization.
test/zonedDateTime.test.ts Adds comprehensive tests for ZonedDateTime construction, parsing, arithmetic, comparisons, and formatting.
test/weekday.test.ts Adds tests for Weekday enum values and ordering.
test/timeZone.test.ts Extends TimeZone tests for fixed offsets, prefixed IDs, normalization, and rule resolution.
test/periodDuration.test.ts Expands PeriodDuration.isZero() coverage via table-driven tests.
test/localTime.test.ts Adds coverage for LocalTime.now/nowIn and new with* APIs.
test/localDateTime.test.ts Updates LocalDateTime.ofInstant expectations and adds tests for nanoseconds, default clock, with*, and atZone.
test/localDate.test.ts Adds coverage for LocalDate.now/nowIn and new with* APIs.
src/zoneRules.ts Introduces ZoneRules abstraction with fixed and Intl-derived region rules + transition probing.
src/zoneOffsetTransition.ts Implements the transition value object used by ZoneRules and ZonedDateTime.
src/zonedDateTime.ts Implements ZonedDateTime with parsing/formatting, transitions handling, arithmetic, and comparisons.
src/timeZone.ts Refactors TimeZone into an abstract base; adds ZoneRegion and ZoneOffset implementations and normalization logic.
src/localTime.ts Adds clock-based now/nowIn and with* field replacement APIs.
src/localDateTime.ts Refactors instant conversion to use rules-based offsets; adds with* APIs and atZone.
src/localDate.ts Adds clock-based now/nowIn and with* field replacement APIs.
src/instant.ts Adds Instant.atZone(...) convenience to create ZonedDateTime.
src/index.ts Exposes new public exports (ZonedDateTime, ZoneOffset, ZoneRules, ZoneOffsetTransition).
README.md Documents Intl-based time-zone behavior and known limitations.
jest.config.js Enforces 100% global coverage and configures coverage collection.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/zonedDateTime.ts Outdated
Comment on lines +123 to +127
return new ZonedDateTime(
LocalDateTime.ofInstant(instant, zone),
zone.getRules().getOffset(instant),
zone,
);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 4ac51c4. The offset is now resolved once and reused to build the local date-time, so it is a single Intl lookup per call.

Comment thread src/timeZone.ts
Comment on lines +426 to +428
public getRules(): ZoneRules {
return ZoneRules.ofFixed(this);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4465961. It now memoizes the rules like getId and ZoneRegion.getRules already do.

Comment thread test/zoneRules.test.ts
Comment on lines +49 to +53
it.each([
['America/Sao_Paulo', '1900-06-01T00:00:00Z', '-03:06:28'],
['Europe/Paris', '1900-06-01T00:00:00Z', '+00:09:21'],
['America/New_York', '1880-06-01T00:00:00Z', '-04:56:02'],
])('resolves the sub-minute historical offset of %s at %s', (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one as is. longOffset needs ICU 69+, which the implementation already depends on, and every runtime that has it ships full tzdata with these LMT offsets. The README caveat is about zones whose early history was dropped, and these three are not among them. These assertions are also the only coverage of the sub-minute branch of the offset regex, so removing them would lose that.

Comment thread src/zonedDateTime.ts Outdated
Comment thread src/zonedDateTime.ts Outdated
Comment thread src/zoneRules.ts
Comment thread src/zoneRules.ts
* representable instant, which is well past the last transition of every zone.
*/
private getOffsetAt(epochSecond: number): number {
const second = Math.max(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clamping the instant may silently return an incorrect offset for region-based zones. For example, both of these instants are clamped to the same native Date, even though New York has different recurring winter and summer offsets:

const rules = TimeZone.of('America/New_York').getRules();
const winter = Instant.parse('+500000-01-15T12:00:00Z');
const summer = Instant.parse('+500000-07-15T12:00:00Z');

expect(rules.getOffset(winter).equals(rules.getOffset(summer))).toBe(false);

Since Intl cannot resolve this range, I think it would be safer to reject unsupported instants instead of returning a plausible but incorrect offset.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, rejecting is better than guessing. Fixed in 1acc7aa.

The check sits at the public entry points (getOffset, getValidOffsets, getTransition, getNextTransition, getPreviousTransition). getOffsetAt still clamps because the probes go 2 days out and the scans 5 years, so they can legitimately run past the boundary from an in-range input.

It is a real gap and not just a far future thing: Instant.MAX is around year 999999 while a native date only reaches 273790, so the whole tail was collapsing onto one offset. Two tests that locked in the old clamping were flipped to expect the error, and the README has a bullet for the new limit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The range check still misses local date-times whose offset moves the resulting instant beyond the native Date boundary:

const zone = TimeZone.of('America/New_York');
const dateTime = LocalDateTime.parse('+275760-09-13T00:00');

expect(() => dateTime.atZone(zone))
    .toThrow('is outside the range the runtime supports.');

This resolves to +275760-09-13T04:00:00Z, which is beyond Date.MAX. Can we validate the instant after applying the candidate offset?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and it was worse than the snippet shows: getOffset on the resulting instant throws, so the zoned date-time you got back held an instant the same rules reject.

Fixed in 937cf96. Instead of checking each candidate instant, I kept a margin from the bound when checking the local date-time. Resolving one probes 2 days on either side and then reads the offset at local - offset, so the furthest read is 2 days plus 18 hours away. Local date-times closer to the bound than that are now rejected.

Nice side effect: the reads around a local date-time can no longer clamp at all, so clamping in getOffsetAt is now only reachable by the transition scan, where it just makes the offset look constant past the bound and reports no transition.

Your snippet throws now, MAX - 237600 still works, and one second past it throws.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the last commit

Comment thread src/localTime.ts

@denis-rossati denis-rossati left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appart from Renan's review, LGTM

@marcospassos
marcospassos requested a lite review from Copilot August 14, 2026 14:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/timeZone.ts:411

  • ZoneOffset.ofTotalSeconds formats invalid inputs using ZoneOffset.formatAmount(totalSeconds). For non-integers this can yield impossible IDs (e.g. +00:00:1.5), and for NaN/Infinity it can throw from intDiv ("The result overflows the range of safe integers.") instead of the intended offset-range error. Consider only calling formatAmount for safe integers and otherwise interpolating the raw value via String(totalSeconds).
        if (!Number.isSafeInteger(totalSeconds) || Math.abs(totalSeconds) > ZoneOffset.MAX_SECONDS) {
            throw new Error(
                'Offset must be between -18:00 and +18:00, '
                + `but got ${ZoneOffset.formatAmount(totalSeconds)}.`,
            );

@marcospassos
marcospassos merged commit 4cde753 into master Aug 14, 2026
7 checks passed
@marcospassos
marcospassos deleted the add-zoned-date-time branch August 14, 2026 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants