Skip to content

Migrate timezone/date utilities to Temporal (bundled polyfill) - #1321

Merged
stefan-burke merged 5 commits into
mainfrom
claude/peaceful-fermat-tbwe1h
Jun 19, 2026
Merged

stefan-burke merged 5 commits into
mainfrom
claude/peaceful-fermat-tbwe1h

Conversation

@stefan-burke

@stefan-burke stefan-burke commented Jun 19, 2026

Copy link
Copy Markdown
Member

Summary

Replaces @internationalized/date with the Temporal API, sourced from the bundled temporal-polyfill rather than the runtime global. All timezone/date logic is server-only (the library never reached the browser bundle), so this is confined to src/shared/timezone.ts + one call in dates.ts.

Why a polyfill, not the native global

Bunny Edge runs either Deno 2.7 or 2.5, and only the built script is deployed — deno.json and any --unstable-temporal flag do not travel with it:

Deno 2.7.x Deno 2.5.x (baseline)
Temporal global stable absent without --unstable-temporal

Relying on the global would throw ReferenceError: Temporal is not defined on the 2.5 baseline, which we can't fix from the artifact. Importing temporal-polyfill bundles the implementation into the script, so the same Temporal runs on both Deno versions and in tests — deterministic regardless of which runtime Bunny lands on.

Verified:

  • Identical test suite passes on both Deno 2.5.6 and 2.7.12 (no flags).
  • The edge bundle inlines the polyfill with zero globalThis.Temporal references, and stays well under Bunny's 10 MB limit (~2.4 MB).

Changes

  • timezone.tsimport { Temporal } from "temporal-polyfill"; todayInTz, localToUtc, isValidDatetime, and shared utcToZoned/msToZoned helpers built on it.
  • dates.tslistingDateToCalendarDate reuses the shared utcToZoned.
  • deno.json / deno.lock — drop @internationalized/date, add temporal-polyfill.

Parity details handled

  1. Milliseconds — Temporal omits zero ms; localToUtc uses { fractionalSecondDigits: 3 } to keep the …:00.000Z format.
  2. Strict validationparseNaiveDateTime rejects offset/zone-bearing input (+09:00, [Asia/Tokyo], Z) before parsing, then { overflow: "reject" } rejects impossible dates (2026-02-30) — matching the old parseDateTime. (Codex review Replace test-searcher with ticket reservation system #1)
  3. Fakeable clocktodayInTz derives "today" from Date.now() (via msToZoned), since Temporal.Now bypasses @std/testing/time's FakeTime. (Codex review Update bunny deploy script for build:edge #2)

Tested

Full typecheck, jscpd (0%), Biome, 100% coverage on changed code; timezone/dates/validation suites and FakeTime/date-touching server suites (329 tests) via the harness — all green on 2.7.12, and the focused suites confirmed on 2.5.6.

🤖 Generated with Claude Code

The timezone/date utilities only ever ran server-side (the library never
entered the browser bundle), and the Deno runtime Bunny Edge Scripting uses
now ships Temporal as a global. Swapping to Temporal removes a runtime
dependency with no behaviour change:

- timezone.ts: todayInTz, localToUtc, isValidDatetime and the shared
  utcToZoned helper now use Temporal. localToUtc keeps the "compatible"
  DST disambiguation and emits millisecond-precision UTC (.000Z) via
  fractionalSecondDigits; isValidDatetime/localToUtc reject impossible
  calendar dates with overflow: "reject", matching parseDateTime.
- dates.ts: listingDateToCalendarDate reuses the shared utcToZoned helper
  instead of the library's fromAbsolute.
- Drop @internationalized/date from deno.json (auto-pruned from deno.lock).

All existing timezone/date tests pass unchanged, including the DST
spring-forward gap and fall-back overlap cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbtZp3QSiFsApxNGSKBGeT

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9026d8ba39

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/timezone.ts Outdated
Comment on lines +42 to +43
return Temporal.PlainDateTime.from(naive, { overflow: "reject" })
.toZonedDateTime(tz, { disambiguation: "compatible" })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject offset-bearing datetime inputs

For submissions that include a numeric offset or bracketed zone, such as 2026-06-15T14:30+09:00 or 2026-06-15T14:30[Asia/Tokyo], Temporal.PlainDateTime.from accepts the string but ignores that offset/zone before converting with settings.timezone. The previous parseDateTime path rejected these non-naive values, so this now silently stores a different instant instead of failing validation; keep the datetime-local parser strict before calling toZonedDateTime.

Useful? React with 👍 / 👎.

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 — confirmed and fixed in b880f8e. Temporal.PlainDateTime.from does silently drop a numeric offset or bracketed zone, where the old parseDateTime rejected them. Both localToUtc and isValidDatetime now go through a shared parseNaiveDateTime helper that requires the strict naive datetime-local shape (no Z/offset/zone) before delegating calendar-validity to overflow: "reject". Added tests for the offset, bracketed-zone, and Z cases.


Generated by Claude Code

Temporal.PlainDateTime.from accepts ISO strings that carry a numeric offset
(2026-06-15T14:30+09:00) or a bracketed IANA zone (2026-06-15T14:30[Asia/Tokyo])
and silently discards the suffix before the value is reinterpreted in
settings.timezone — storing a different instant than the string implies. The
previous parseDateTime path rejected these non-naive values outright.

Gate both parsers behind a shared parseNaiveDateTime helper that requires the
strict naive datetime-local shape (calendar date + optional wall-clock time,
no Z/offset/zone) before delegating calendar-validity to Temporal's
overflow: "reject". Restores the stricter validation the migration dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbtZp3QSiFsApxNGSKBGeT

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b880f8ed05

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/timezone.ts Outdated
*/
export const todayInTz = (tz: string): string => libToday(tz).toString();
export const todayInTz = (tz: string): string =>
Temporal.Now.plainDateISO(tz).toString();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the fakeable clock for todayInTz

When date-dependent code is exercised under @std/testing/time's FakeTime, this now reads the real wall clock because FakeTime overrides Date/timers while Temporal.Now uses the runtime clock directly. The previous @internationalized/date today() path was backed by Date.now(), so fake-time tests for booking windows, holiday cutoffs, or delivery/calendar pages can now calculate “today” from the actual date instead of the frozen date; derive the Temporal instant from Date.now() before converting to the requested timezone to keep this helper controllable.

Useful? React with 👍 / 👎.

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.

Confirmed and fixed in 8ba59c1. Verified empirically: under FakeTime frozen at 2030-01-15, Temporal.Now.plainDateISO returned the real date while a Date.now()-derived instant returned the frozen one. todayInTz now derives "today" from Date.now() (via a shared msToZoned helper that utcToZoned also uses), restoring the freezable behaviour the old today() had. Added a FakeTime test that locks it in. server-qr-book/renewals (FakeTime + todayInTz) still pass via the harness.


Generated by Claude Code

Temporal.Now reads the host clock directly, which @std/testing/time's
FakeTime does not patch (it overrides Date/timers). The previous
@internationalized/date today() path was backed by Date.now(), so frozen-time
tests for booking windows, holiday cutoffs and calendar/delivery pages could
control "today"; after the Temporal migration todayInTz silently read the real
date instead.

Derive the instant from Date.now() (via a shared msToZoned helper that
utcToZoned now also uses) so the helper stays freezable. Adds a FakeTime test
asserting todayInTz honours the frozen clock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbtZp3QSiFsApxNGSKBGeT
@stefan-burke stefan-burke changed the title Replace @internationalized/date with the built-in Temporal API Migrate timezone/date utilities to Temporal (bundled polyfill) Jun 19, 2026
Only the built edge script is deployed to Bunny — deno.json and its unstable
flags do not travel with it — and Bunny may run either Deno 2.7 (Temporal is a
stable global) or 2.5 (Temporal is unavailable without --unstable-temporal,
which we cannot pass to the edge runtime). Relying on the global would throw
ReferenceError on the 2.5 baseline.

Import Temporal from `temporal-polyfill` so the implementation is bundled into
the artifact and runs identically regardless of the host Deno version (and in
tests). Verified the same suite passes on both Deno 2.5.6 and 2.7.12, the edge
bundle inlines the polyfill with no global Temporal reference, and stays well
under Bunny's 10MB script limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbtZp3QSiFsApxNGSKBGeT
@stefan-burke
stefan-burke force-pushed the claude/peaceful-fermat-tbwe1h branch from 38e3471 to 13eece6 Compare June 19, 2026 16:32
Temporal.PlainDateTime.from clamps a ":60" leap second to ":59" even under
overflow: "reject", so a crafted value like 2026-06-15T14:30:60 passed
isValidDatetime and localToUtc stored it one second earlier — where the old
@internationalized/date parseDateTime rejected it outright.

Constrain the regex time fields to real ISO ranges (HH 00–23, MM/SS 00–59)
so the leap second is rejected at the shape check rather than silently
shifted. Out-of-range hour/minute were already rejected by Temporal; calendar
validity (real month/day) is still delegated to overflow: "reject". Also
correct the doc comment: Temporal rejects a bare `Z` but only discards a
numeric offset or bracketed zone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbtZp3QSiFsApxNGSKBGeT
@stefan-burke
stefan-burke added this pull request to the merge queue Jun 19, 2026
Merged via the queue into main with commit 029aabd Jun 19, 2026
@stefan-burke
stefan-burke deleted the claude/peaceful-fermat-tbwe1h branch June 19, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants