Revision history for Perl distribution DateTime-Lite
v0.6.6 2026-05-03T21:09:36+0900
[Bug Fixes]
- Corrected fallback handler. XSLoader fallback to pure-Perl was not triggered on
platforms with old compilers (such as FreeBSD 9.2 with GCC 4.2) that emit an
"Undefined symbol" error for __builtin_unreachable. The error filter regex has
been tuned to cover this case.
- Added, or corrected the following missing or broken methods to the pure-Perl
fallback (PERL_DATETIME_LITE_PP=1):
- clone(): fallback was added in DateTime::Lite.
- _compare(): in DateTime::Lite, moved is_infinite() check before the
floating-zone clone block to prevent incorrect behaviour when comparing
Infinite objects in pure-Perl.
- _posix_tz_lookup(): in DateTime::Lite::TimeZone, corrected to add pure-Perl
fallback.
- DateTime::Lite::PP: removed dependency on DateTime::LeapSecond (part of the
DateTime distribution). The leap second table is now inlined directly from
leap_seconds.h, making the pure-Perl fallback fully self-contained.
- DateTime::Lite::PP: _normalize_tai_seconds() and _normalize_leap_seconds()
did not short-circuit on non-finite values (Inf/-Inf), causing
DateTime::Lite::Infinite::Future and DateTime::Lite::Infinite::Past objects
to be constructed with corrupted utc_rd_days (-2 instead of Inf).
Both functions now return immediately when the seconds argument is not a
finite number, mirroring the dtl_isfinite() guard in the XS implementation.
[CI]
- Added a new job in the CI pipeline: perl-5.38-pure-perl to verify the full test
suite passes on every release with PERL_DATETIME_LITE_PP=1.
v0.6.5 2026-04-24T15:17:55+0900
[Bug Fixes]
- Added %O in the supported patterns tokens for strftime(). It returns now the
IANA timezone name via $dt->time_zone->name (such as Asia/Tokyo or Europe/London).
This makes %O usable symmetrically for both parsing and formatting in conjunction
with DateTime::Format::Lite.
- Added %E (abbreviated weekday name, alias for %a) and %h (abbreviated month
name, alias for %b) that were missing from the strftime supported patterns tokens.
- Corrected %{n}N patterns (such as %3N for milliseconds and %6N for microseconds)
that were returning 9 digits regardless of the specified precision.
The N entry in the strftime dispatch table was ignoring its second argument
(the digit count captured by the %(\d+)N regex branch).
[New Feature]
- Added %:z to strftime(), returning the UTC offset with a colon separator
(such as +01:00 or -05:00). This is a GNU extension also supported by Ruby and
Python, useful for producing W3C/RFC 3339 formatted timestamps. The strftime
regex has been extended to handle this multi-character specifier.
[Improvement]
- Improved the POD for DateTime::Lite with a detailed section on supported strftime
tokens.
v0.6.4 2026-04-23T16:06:35+0900
[Maintenance]
- SQLite database updated with the newly released IANA data version 2026b.
[New Features]
- DateTime::Lite::TimeZone->new() now accepts an C<extended> boolean option.
When set to a true value and the supplied name is not recognised as a valid
IANA timezone name, new() calls resolve_abbreviation() with extended => 1
internally and, if a single unambiguous candidate is found, recurses with the
resolved canonical name. This allows timezone abbreviations such as JST, CET,
or EST to be passed directly to new() without requiring the caller to call
resolve_abbreviation() explicitly. See synopsis for examples.
- DateTime::Lite->new() and set_time_zone() now accept a hash reference for the
time_zone parameter. The hash reference is passed directly to
DateTime::Lite::TimeZone->new(), allowing options such as extended => 1 or
coordinate-based resolution to be specified inline.
[Bug Fixes]
- DateTime::Lite->_new() now validates that a reference passed as time_zone
is a blessed DateTime::Lite::TimeZone object, returning a clear error
instead of silently misbehaving with an unexpected reference type.
- Fixed: offset_for_datetime() and offset_for_local_datetime() returned an
incorrect offset for timezones that abolished DST after having used it, such as
Asia/Tehran (DST abolished September 2022).
The POSIX footer string (<+0330>-3:30 for Tehran) was applied unconditionally
whenever a footer was present, overwriting historically correct bounded DST
spans with the post-abolition rule.
The footer is now only applied when the matched database span is open-ended
(utc_end IS NULL / local_end IS NULL), which means that the timestamp is beyond
all stored transitions.
For timestamps within stored transitions, the database span is returned as-is.
Also, both span lookup queries now use:
ORDER BY (utc_start IS NULL) ASC, utc_start DESC (resp. local_start)
to guarantee deterministic results when multiple spans satisfy the WHERE clause,
because without ORDER BY, LIMIT 1 is non-deterministic across SQLite versions.
The IS NULL expression is used instead of the NULLS LAST syntax, because it is
only available from SQLite 3.30.0 (2019-10-04) onward and would fail on older
installations.
Reported by Andrew Grechkin (@andrew-grechkin), GitLab issue #2.
[Thread Safety]
- DateTime::Lite::TimeZone: the package-level database handle cache ($DBH)
and prepared statement cache ($STHS) are now keyed by thread ID (or PID
when not running under threads). This prevents a DBD::SQLite error, such as
"handle 2 is owned by thread aaaae23f22a0 not current thread"
when multiple threads call timezone methods concurrently.
The thread ID is obtained via threads->tid() when $Config{useithreads} is true,
threads.pm is loaded, and threads->can('tid') is available; otherwise $$ is used.
v0.6.3 2026-04-20T21:57:34+0900
[Improvement]
- In resolve_abbreviation(), reworked the default sort order for IANA
(non-extended) results returned by DateTime::Lite::TimeZone->resolve_abbreviation().
The previous order (ORDER BY MAX(tr.trans_time) DESC) produced counter-intuitive
top results such as CEST -> Africa/Tripoli first.
- The new ordering is a four-level key:
abbreviation come first)
For CEST, this puts Europe/Berlin first. For PST, America/Los_Angeles.
For JST, Asia/Tokyo. For WET, Atlantic/Faroe. The ordering now matches
user intuition as closely as the data allows.
1. is_active DESC (zones whose POSIX footer still references the
2. first_trans_time ASC (earliest adopter of the abbreviation wins)
3. last_trans_time DESC (tie-break by persistence)
4. zone_name ASC (deterministic final tie-breaker)
- Added two new fields to IANA results:
- 'is_active': 1 if the zone's POSIX footer_tz_string still references the
abbreviation; 0 otherwise. This is computed post-query via regex.
- 'first_trans_time': Unix epoch of the earliest transition in this zone
using the abbreviation (complements the existing last_trans_time).
- Extended alias results (when extended is set to true, falls back to
extended_aliases table) keep their previous shape: 'is_primary' instead of
'is_active', no 'first_trans_time'. The two branches intentionally expose
different ordering signals because their sources of authority differ
(curated editorial for extended, transition data for IANA).
[Documentation]
- Rewrote the POD for resolve_abbreviation() for is_primary and added a new
section "USING Locale::Unicode::Data FOR CANONICAL DESIGNATION" that explains
why no single zone is marked as "primary" among IANA candidates of an
abbreviation, and demonstrates how to delegate canonical designation to
Locale::Unicode::Data (which exposes CLDR's 'is_golden', 'is_primary',
'is_preferred' flags via its metazones mapping).
- Updated the POD examples for resolve_abbreviation() to show the new result
shape ('is_active', 'first_trans_time') and documented the four-level sort order
in prose.
[Tests]
- Updated t/17.resolve_abbreviation_period.t to assert the new sort order, and
added two subtests checking that IANA results carry 'is_active', and that
extended results carry 'is_primary' (but not 'is_active').
- Added new test suite t/18.resolve_abbreviation_ordering.t
[Performance]
- Measured the cost of the new sort: roughly +35 microseconds per call
on the full 178-abbreviation test set (around +17% over the v0.6.2
baseline). Memory overhead is two additional hash keys per result row
(approximately 50 bytes per row).
v0.6.2 2026-04-19T14:15:33+0900
[Critical Bug Fixes in DateTime::Lite::TimeZone]
- Moved 'use strict' and 'use warnings' out of the BEGIN block and into
package-level scope. This hid some latent bugs fixed below.
- Fixed nine class-callable methods that referenced an undeclared $self
variable throughout their bodies, where only $class_or_self was actually assigned
from the first shifted argument.
Without strict in effect, these references silently evaluated to undef or crashed
at runtime when called. The affected methods are:
- aliases()
- all_names()
- categories()
- countries()
- is_valid_name()
- names_in_category()
- names_in_country()
- offset_as_seconds()
- and offset_as_string().
All nine now work correctly when invoked as class methods, as intended and
documented.
- Fixed _get_zone_info() which ignored its $name parameter and used object-based
properties even when called as a class function, which would have triggered a
perl error.
- Modified fatal() to use the global variable $FATAL_EXCEPTIONS when called as a
class function, or the 'fatal' instance property.
- Fixed names_in_country(): the prepared statement cache key was incorrectly shared
with names_in_category() (both using 'names_in_category'), so whichever method was
called second would retrieve the other method's prepared statement and produce
silently wrong results.
The cache key is now 'names_in_country'. The method now also correctly passes the
$code argument to execute() (it was previously called with no arguments).
- Fixed names_in_category() which called execute() without passing the $cat argument,
so the SQL placeholder bound to NULL and the method returned zero matches for every
input. Now passes $cat to execute().
- Fixed categories() query which returned an empty-string entry from the Factory zone
(which has a NULL category in IANA data). Query now filters with 'WHERE category
IS NOT NULL'.
- Fixed bad variable name $row used in aliases(), all_names(),
categories(), countries(), names_in_category(), and names_in_country().
[Defensive Guards]
- Added instance guards to eight class-callable methods that require a blessed
instance: category(), country_codes(), has_dst_changes(), is_dst_for_datetime(),
offset_for_datetime(), offset_for_local_datetime(), short_name_for_datetime(),
and tzif_version(). These return a clean error via $self->error() rather than
dereferencing a class name string under strict refs.
- Added die() guards in _set_get_prop() and _lookup_span() to catch internal design
errors where a class name is passed instead of an instance. These are private
methods, so a die() (rather than a clean return) is appropriate: reaching them in
class context indicates a caller bug that should surface immediately.
[API Additions]
- Introduced new package variable $FATAL_EXCEPTIONS for DateTime::Lite::TimeZone,
similar to the one used in DateTime::Lite.
When set to a true value, newly constructed DateTime::Lite::TimeZone objects
default to fatal set to true. Per-object fatal flags remain individually settable
via the fatal() accessor/mutator.
v0.6.1 2026-04-19T08:22:20+0900
- Fixed _dbh_add_user_defined_functions() in DateTime::Lite::TimeZone to
correctly handle SQLite installations where pragma_function_list is
unavailable.
The previous implementation queried pragma_function_list unconditionally to
detect whether SQLite's built-in math functions (sqrt, sin, cos, asin) were
compiled in (with the macro 'SQLITE_ENABLE_MATH_FUNCTIONS'). That table-valued
pragma is only available since SQLite 3.16.0 (2017-01-02).
So, older installations raised the error: "no such table: pragma_function_list"
causing t/14.tz_coordinates.t to fail entirely.
The detection logic is now version-aware, based on $DBD::SQLite::sqlite_version:
- SQLite >= 3.35.0 (2021-03-12): pragma_function_list is queried for 'sqrt'.
The check runs before any UDF is registered, so a hit is guaranteed to be a
native function. A miss means the build omitted -DSQLITE_ENABLE_MATH_FUNCTIONS;
Perl UDFs are registered as fallback.
- SQLite >= 3.16.0 and < 3.35.0: pragma_function_list exists but the math
functions cannot be present regardless of the build flags;
Perl UDFs are registered unconditionally without querying the pragma.
- SQLite < 3.16.0: pragma_function_list is not available as a table-valued
function, so Perl UDFs are registered unconditionally.
UDFs via sqlite_create_function() are available on all SQLite >= 3.0.0, so
coordinate-based timezone resolution now works transparently on all supported
SQLite versions.
Reported via CPAN Testers on Perl 5.20.0, 5.22.2 and 5.24.0 on x86_64-linux
(Debian Wheezy, system SQLite < 3.16.0).
Thanks to Slaven Rezić for the detailed test reports.
- Added .gitlab-ci.yml. The pipeline covers Perl 5.10.1 through 5.40 on Linux,
plus a dedicated job that builds SQLite 3.15.2 from the official autoconf tarball
(the last release before 3.16.0) to ensure the unconditional UDF registration path
is exercised in CI.
v0.6.0 2026-04-17T22:30:20+0900
- Added extended_aliases table to tz.sqlite3 (schema v0.6.0).
abbreviations not stored as TZif type entries in the IANA database, such as BDT,
CEST, HAEC, JST, and the full set of NATO military single-letter zones.
Each abbreviation maps to one or more canonical IANA zone names.
One row per (abbreviation, zone_id) pair with 'is_primary' marking the most
commonly accepted canonical zone when multiple candidates exist.
Two triggers enforce that at most one is_primary = 1 row exists per abbreviation
(on INSERT and on UPDATE OF is_primary).
355 timezone abbreviations, 535 (abbreviation, zone) pairs, covering real-world
- Updated tz.sqlite3 with new schema and latest data (tzdata 2026a).
- Extended resolve_abbreviation() in DateTime::Lite::TimeZone with an optional
'extended' boolean argument.
When true and the abbreviation is absent from the IANA types table, the method
falls back to querying the new table 'extended_aliases'.
Extended results carry utc_offset => undef and is_dst => undef (the extended
alias table maps names only); the new 'is_primary' and 'extended' keys are added
to each result hashref.
The ambiguity flag for extended results is based on candidate count and
'is_primary': 'ambiguous' is set to 0 when exactly one 'is_primary' exists,
otherwise, 'ambiguous' is set to 1.
Existing callers are unaffected: without the option 'extended' set to a true value,
the behaviour is identical to the previous version v0.5.0.
- Added the property 'extended' set to 0 in all IANA results returned from
resolve_abbreviation() for consistency with extended alias results.
- Added cached prepared statement resolve_abbreviation_extended for the
'extended_aliases' table query, consistent with existing statement caching.
- Updated POD for resolve_abbreviation() to document the boolean option 'extended',
'utc_offset', and the result fields 'extended' and 'is_primary', and the 'ambiguity'
semantics for extended results.
- Added t/16.extended_aliases.t with 6 subtests covering:
- IANA abbreviation with the boolean option 'extended' as a no-op (CEST)
- unambiguous single-zone extended alias (AFT -> Asia/Kabul)
- multi-zone extended alias with 'is_primary' (AMST)
- unknown abbreviation with and without the boolean option 'extended'
- JST IANA offset verification; and
- round-trip: using the returned 'zone_name' to instantiate a
DateTime::Lite::TimeZone object.
- Extended resolve_abbreviation() with period-based filtering and deterministic
sort order:
- Results are now sorted by most-recently-used first
(MAX(trans_time) DESC), so the currently-active or most-recently-active zone
appears first. The new result field 'last_trans_time' exposes the Unix epoch
of that most recent transition.
- Added optional 'period' argument to restrict results to zones whose most recent
matching transition falls within a given time window.
Accepts a single value or an array reference of values for multiple AND-combined
conditions. Each value may be prefixed with a comparison operator: >, >=, <, <=.
ISO date strings such as '1950-01-01' are converted to Unix epoch via SQLite
strftime(); plain integers are passed as CAST(? AS INTEGER) to ensure
arithmetic comparison.
The special value 'current' returns only zones where the abbreviation is active
right now, by checking that the most recent matching transition is the zone's
overall most recent transition and is in the past.
- Added t/17.resolve_abbreviation_period.t with 10 subtests for abbreviated timezone
resolution.
v0.5.0 2026-04-16T15:26:07+0900
- Added the method resolve_abbreviation() in DateTime::Lite::TimeZone
- Corrected method _nearest_zone() to use $class instead of $self
- Implemented a check for the availability of SQLite math functions
(SQLite version >= 3.35.0; March 2021)
- Added a private method, _dbh_add_user_defined_functions(), to add the missing math
functions with User Defined Functions if missing. _dbh_add_user_defined_functions()
is only called by relevant methods, so those math functions are added only when
necessary.
v0.4.0 2026-04-14T10:27:25+0900
- Fixed t/12.tz_database.t on Windows (Strawberry Perl): replaced
POSIX::mktime with Time::Local::timegm for pre-1970 timestamps,
which POSIX::mktime cannot handle on Windows.
- Added start_of( $unit ) mutator, which modifies the datetime object in place
to the first instant of the given unit.
Supported units are second, minute, hour, day, week, local_week, month, quarter,
year, decade, and century.
Delegates to truncate() for most units; decade and century are handled independently.
Returns the modified object on success.
- Added end_of( $unit ) mutator: modifies the datetime object in place to
the last nanosecond of the given unit (i.e. the nanosecond before the
start of the next unit).
Supports the same units as start_of().
Handles variable-length units such as months and leap years correctly without
hardcoding boundary values.
Returns the modified object on success.
- Added test suite t/15.start_end_of.t covering all supported units for both
start_of() and end_of(), including edge cases such as February in leap and
non-leap years, quarter boundaries, decade and century boundaries, timezone and
locale preservation, and invalid unit handling.
- Added POD documentation for posix_tz_lookup() under a new =head1 LOW-LEVEL XS UTILITIES
section in DateTime::Lite, documenting the XS function signature, all supported
POSIX TZ string rule forms (Jn, n, Mm.w.d), the RFC 9636 extensions for TZif v3+,
and the structure of the returned hashref.
v0.3.0 2026-04-13T07:30:23+0900
- Added GPS coordinate-based timezone resolution to DateTime::Lite::TimeZone::new().
Passing C<latitude> and C<longitude> (decimal degrees) instead of C<name>
finds the nearest canonical IANA timezone using the haversine great-circle
distance against the reference coordinates stored in the C<zones> table of C<tz.sqlite3> (derived from
IANA C<zone1970.tab>). No new dependencies.
Note: this is an approximation based on one representative point per zone;
it is accurate for most locations but may give incorrect results near timezone
boundaries or in enclaves. For more boundary-precise resolution, see
L<Geo::Location::TimeZoneFinder>.
- Added private method _nearest_zone() to DateTime::Lite::TimeZone, implementing
the haversine query entirely within SQLite via asin(), sqrt(), cos() and sin()
functions. Only canonical zones with coordinates are considered.
- Added input validation for latitude (-90..90) and longitude (-180..180), with
proper error() returns rather than die().
- Updated DateTime::Lite::TimeZone POD to document the coordinate-based resolution,
its approximation caveat, and the valid parameter ranges.
- Added t/14.tz_coordinates.t with 10 subtests covering: Tokyo, Paris, New York,
Taipei, Sydney, Buenos Aires (southern hemisphere); integration with
DateTime::Lite->now(); and input validation for missing, out-of-range, and
non-numeric parameters.
v0.2.0 2026-04-11T20:48:09+0900
- BCP47 -u-tz- locale extension support: if the locale tag carries a
Unicode timezone extension, such as C<he-IL-u-ca-hebrew-tz-jeruslm>,
and no explicit C<time_zone> argument is supplied to the constructor,
the corresponding IANA canonical timezone name is resolved automatically
via C<Locale::Unicode->tz_id2names()>.
Explicit C<time_zone> always takes priority.
No new dependencies: the BCP47 timezone to Olson canonical timezone
resolution uses the static in-memory hash in C<Locale::Unicode>, so no
SQLite query, and thus super fast.
- _set_locale() rewritten: now uses C<Scalar::Util::blessed()> instead
of C<ref()> to distinguish objects from unblessed references; This was
a carry over from the way DateTime is doing.
Returns the C<DateTime::Locale::FromCLDR> object instead of C<undef> to allow
the BCP47 timezone resolution in C<_new()>.
Unblessed references now return a proper error instead of an undefined behaviour;
replaced C<die()> with C<pass_error()> for consistency with the no-die philosophy.
- Added support for 'local' timezone name in DateTime::Lite::TimeZone::new().
The local timezone is resolved automatically without any external module
dependency, using OS-specific detection strategies:
- Unix/Linux/macOS/FreeBSD/OpenBSD/NetBSD/Solaris/AIX/HP-UX/OS2/Cygwin:
$ENV{TZ}, /etc/localtime symlink or binary match, /etc/timezone,
/etc/TIMEZONE, /etc/sysconfig/clock, /etc/default/init.
- Windows (MSWin32, NetWare): $ENV{TZ} then Windows Registry via
Win32::TieRegistry (optional, not a hard dependency).
- Android: $ENV{TZ}, getprop persist.sys.timezone, then 'UTC'.
- VMS: TZ, SYS$TIMEZONE_RULE, SYS$TIMEZONE_NAME, UCX$TZ, TCPIP$TZ.
- Symbian, EPOC, MS-DOS, Mac OS 9 and earlier: $ENV{TZ} only.
- Added OS dispatch aliases for darwin, cygwin, freebsd, openbsd, netbsd, solaris,
aix, hpux, os2, netware, symbian, epoc, dos, macos.
- Updated DateTime::Lite::TimeZone POD to document 'local' resolution strategy per OS.
- Added t/10.timezone.t subtest for 'local' timezone resolution via $ENV{TZ}.
v0.1.0 2026-04-10T06:12:47+0900
- Initial release.
- Full port of DateTime 1.66 API.
- Dependencies reduced from ~10 non-core to 3 (DateTime::TimeZone,
DateTime::Locale::FromCLDR, Locale::Unicode).
- Specio, Params::ValidationCompiler, Try::Tiny, namespace::autoclean all
eliminated.
- XS layer ported from DateTime.xs with 4 new functions:
_rd_to_epoch, _epoch_to_rd, _normalize_nanoseconds, _compare_rd.
- Pure-Perl fallback (DateTime::Lite::PP) for environments without a C compiler.
- No die() in normal error paths: DateTime::Lite::Exception used throughout,
errors returned as undef in scalar context.
- DateTime::Lite::Infinite provides singleton Future/Past objects.
- Locale data via DateTime::Locale::FromCLDR + Locale::Unicode::Data (SQLite backend,
no generated Perl locale files).
- Includes DateTime::Lite::TimeZone based thoroughly on the IANA Olson data
accessible with a SQLite database.
- Replaced the shallow Perl clone() with an XS deep copy (DateTime-Lite.xs):
- Root object scalar fields are copied with newSVsv().
- Nested blessed hashrefs (tz, locale) are copied with a new static
helper dtl_clone_flat_hv(), then re-blessed into the original stash.
The RV is created first with newRV_noinc() before calling sv_bless(),
which requires an RV not a raw HV pointer.
- The local_c cache (plain hashref) is also deep-copied, eliminating
any shared state between original and clone.
- Non-HV references (formatter objects, etc.) are shallow-copied with
newSVsv(), acceptable since formatters are effectively immutable.
- Implemented a TZif footer POSIX parser in XS, based on IANA code (public domain).
- New file dtl_posix.h: self-contained C header containing eight functions
derived from tzcode localtime.c (is_digit, getzname, getqzname, getnum,
getsecs, getoffset, getrule, transtime) plus dtl_year_to_jan1() and the
public entry point dtl_posix_tz_lookup(). All symbols prefixed dtl_;
no dynamic allocation, no system calls, no global state.
- New XS function DateTime::Lite::posix_tz_lookup(class_or_self,
unix_secs, tz_string): parses any POSIX TZ footer and returns
{ offset, is_dst, short_name } or undef. Handles Jn, n, Mm.w.d
rules; quoted <name> abbreviations; fractional offsets; negative and
>24 h transition times (RFC 9636 s3.3.2 v3+ extensions); southern-
hemisphere DST (start > end).
- DateTime::Lite::TimeZone::_posix_tz_lookup() is now a thin Perl wrapper that
delegates to the XS function.
- Implemented process-level memory cache to DateTime::Lite::TimeZone.
The cache is opt-in and activated either per-call or class-wide:
use_cache_mem => 1 argument to new() enables it for that call;
DateTime::Lite::TimeZone->enable_mem_cache enables it globally.
Three new class methods: enable_mem_cache(), disable_mem_cache(),
clear_mem_cache(). Cache is keyed by both input name and canonical
name after alias resolution, so "US/Eastern" and "America/New_York"
share the same cached object.
- Implemented three-layer span cache to DateTime::Lite::TimeZone, active
when use_cache_mem => 1 or enable_mem_cache() is set:
Layer 1 - object cache: TimeZone->new() returns the cached object
keyed by zone name and canonical name, bypassing SQLite entirely.
Layer 2 - span cache: _lookup_span() and _lookup_span_local() store
the last matched span boundaries per cached TZ object. Subsequent
calls within the same span return the cached result in ~0.5 us
instead of ~45 us (SQL query). SQL extended with utc_start/utc_end
and local_start/local_end columns for range checks.
Layer 3 - POSIX footer cache: For zones with a footer TZ string
(e.g. America/New_York), the DST rule calculation result is cached
by calendar day. Pre-check added BEFORE the SQL query so future
dates skip the database entirely on subsequent calls.