Migrate timezone/date utilities to Temporal (bundled polyfill) - #1321
Conversation
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
There was a problem hiding this comment.
💡 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".
| return Temporal.PlainDateTime.from(naive, { overflow: "reject" }) | ||
| .toZonedDateTime(tz, { disambiguation: "compatible" }) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
💡 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".
| */ | ||
| export const todayInTz = (tz: string): string => libToday(tz).toString(); | ||
| export const todayInTz = (tz: string): string => | ||
| Temporal.Now.plainDateISO(tz).toString(); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
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
38e3471 to
13eece6
Compare
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
Summary
Replaces
@internationalized/datewith the Temporal API, sourced from the bundledtemporal-polyfillrather than the runtime global. All timezone/date logic is server-only (the library never reached the browser bundle), so this is confined tosrc/shared/timezone.ts+ one call indates.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.jsonand any--unstable-temporalflag do not travel with it:Temporalglobal--unstable-temporalRelying on the global would throw
ReferenceError: Temporal is not definedon the 2.5 baseline, which we can't fix from the artifact. Importingtemporal-polyfillbundles 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:
globalThis.Temporalreferences, and stays well under Bunny's 10 MB limit (~2.4 MB).Changes
timezone.ts—import { Temporal } from "temporal-polyfill";todayInTz,localToUtc,isValidDatetime, and sharedutcToZoned/msToZonedhelpers built on it.dates.ts—listingDateToCalendarDatereuses the sharedutcToZoned.deno.json/deno.lock— drop@internationalized/date, addtemporal-polyfill.Parity details handled
localToUtcuses{ fractionalSecondDigits: 3 }to keep the…:00.000Zformat.parseNaiveDateTimerejects offset/zone-bearing input (+09:00,[Asia/Tokyo],Z) before parsing, then{ overflow: "reject" }rejects impossible dates (2026-02-30) — matching the oldparseDateTime. (Codex review Replace test-searcher with ticket reservation system #1)todayInTzderives "today" fromDate.now()(viamsToZoned), sinceTemporal.Nowbypasses@std/testing/time'sFakeTime. (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