Skip to content

Commit 21114e4

Browse files
authored
Support Windows system time zone transitions in all years (#15891)
Adds a new `Time::WindowsLocation` class that handles month + week + day-of-week transitions both before the first fixed transition and after the last fixed transition. This eliminates the need to arbitrarily store 201 years' worth of fixed transitions in any Windows system time zone. The side effect is that there are indeed no fixed transitions now, these will have to wait until #13518.
1 parent 268c7cb commit 21114e4

File tree

3 files changed

+93
-49
lines changed

3 files changed

+93
-49
lines changed

spec/std/time/location_spec.cr

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,19 @@ class Time::Location
300300

301301
with_system_time_zone(info) do
302302
location = Location.load_local
303-
location.zones.should eq [Time::Location::Zone.new("CET", 3600, false), Time::Location::Zone.new("CEST", 7200, true)]
303+
std_zone = Time::Location::Zone.new("CET", 3600, false)
304+
dst_zone = Time::Location::Zone.new("CEST", 7200, true)
305+
location.zones.should eq [std_zone, dst_zone]
306+
307+
location.lookup(Time.utc(2000, 10, 29, 0, 59, 59)).should eq(dst_zone)
308+
location.lookup(Time.utc(2000, 10, 29, 1, 0, 0)).should eq(std_zone)
309+
location.lookup(Time.utc(2001, 3, 25, 0, 59, 59)).should eq(std_zone)
310+
location.lookup(Time.utc(2001, 3, 25, 1, 0, 0)).should eq(dst_zone)
311+
312+
location.lookup(Time.utc(3000, 10, 26, 0, 59, 59)).should eq(dst_zone)
313+
location.lookup(Time.utc(3000, 10, 26, 1, 0, 0)).should eq(std_zone)
314+
location.lookup(Time.utc(3001, 3, 29, 0, 59, 59)).should eq(std_zone)
315+
location.lookup(Time.utc(3001, 3, 29, 1, 0, 0)).should eq(dst_zone)
304316
end
305317
end
306318

src/crystal/system/win32/time.cr

Lines changed: 17 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ module Crystal::System::Time
7070
end
7171

7272
def self.load_localtime : ::Time::Location?
73-
if LibC.GetTimeZoneInformation(out info) != LibC::TIME_ZONE_ID_INVALID
74-
initialize_location_from_TZI(info, "Local")
73+
if LibC.GetDynamicTimeZoneInformation(out info) != LibC::TIME_ZONE_ID_INVALID
74+
windows_name = String.from_utf16(info.timeZoneKeyName.to_slice, truncate_at_null: true)
75+
initialize_location_from_TZI(pointerof(info).as(LibC::TIME_ZONE_INFORMATION*).value, "Local", windows_name)
7576
end
7677
end
7778

@@ -105,70 +106,38 @@ module Crystal::System::Time
105106
)
106107
WindowsRegistry.get_raw(sub_handle, Std, tzi.standardName.to_slice.to_unsafe_bytes)
107108
WindowsRegistry.get_raw(sub_handle, Dlt, tzi.daylightName.to_slice.to_unsafe_bytes)
108-
initialize_location_from_TZI(tzi, iana_name)
109+
initialize_location_from_TZI(tzi, iana_name, windows_name)
109110
end
110111
end
111112
end
112113

113-
private def self.initialize_location_from_TZI(info, name)
114+
private def self.initialize_location_from_TZI(info, name, windows_name)
114115
stdname, dstname = normalize_zone_names(info)
115116

116-
if info.standardDate.wMonth == 0_u16
117+
if info.standardDate.wMonth == 0_u16 || info.daylightDate.wMonth == 0_u16
117118
# No DST
118119
zone = ::Time::Location::Zone.new(stdname, info.bias * BIAS_TO_OFFSET_FACTOR, false)
119-
return ::Time::Location.new(name, [zone])
120+
default_tz_args = {0, 0, ::Time::TZ::MonthWeekDay.default, ::Time::TZ::MonthWeekDay.default}
121+
return ::Time::WindowsLocation.new(name, [zone], windows_name, default_tz_args)
120122
end
121123

122124
zones = [
123125
::Time::Location::Zone.new(stdname, (info.bias + info.standardBias) * BIAS_TO_OFFSET_FACTOR, false),
124126
::Time::Location::Zone.new(dstname, (info.bias + info.daylightBias) * BIAS_TO_OFFSET_FACTOR, true),
125127
]
126128

127-
first_date = info.standardDate
128-
second_date = info.daylightDate
129-
first_index = 0_u8
130-
second_index = 1_u8
129+
std_index = 0
130+
dst_index = 1
131+
transition1 = systemtime_to_mwd(info.daylightDate)
132+
transition2 = systemtime_to_mwd(info.standardDate)
133+
tz_args = {std_index, dst_index, transition1, transition2}
131134

132-
if info.standardDate.wMonth > info.daylightDate.wMonth
133-
first_date, second_date = second_date, first_date
134-
first_index, second_index = second_index, first_index
135-
end
136-
137-
transitions = [] of ::Time::Location::ZoneTransition
138-
139-
current_year = ::Time.utc.year
140-
141-
(current_year - 100).upto(current_year + 100) do |year|
142-
tstamp = calculate_switchdate_in_year(year, first_date) - (zones[second_index].offset)
143-
transitions << ::Time::Location::ZoneTransition.new(tstamp, first_index, first_index == 0, false)
144-
145-
tstamp = calculate_switchdate_in_year(year, second_date) - (zones[first_index].offset)
146-
transitions << ::Time::Location::ZoneTransition.new(tstamp, second_index, second_index == 0, false)
147-
end
148-
149-
::Time::Location.new(name, zones, transitions)
135+
::Time::WindowsLocation.new(name, zones, windows_name, tz_args)
150136
end
151137

152-
# Calculates the day of a DST switch in year *year* by extrapolating the date given in
153-
# *systemtime* (for the current year).
154-
#
155-
# Returns the number of seconds since UNIX epoch (Jan 1 1970) in the local time zone.
156-
private def self.calculate_switchdate_in_year(year, systemtime)
157-
# Windows specifies daylight savings information in "day in month" format:
158-
# wMonth is month number (1-12)
159-
# wDayOfWeek is appropriate weekday (Sunday=0 to Saturday=6)
160-
# wDay is week within the month (1 to 5, where 5 is last week of the month)
161-
# wHour, wMinute and wSecond are absolute time
162-
::Time.month_week_date(
163-
year,
164-
systemtime.wMonth.to_i32,
165-
systemtime.wDay.to_i32,
166-
systemtime.wDayOfWeek.to_i32,
167-
systemtime.wHour.to_i32,
168-
systemtime.wMinute.to_i32,
169-
systemtime.wSecond.to_i32,
170-
location: ::Time::Location::UTC,
171-
).to_unix
138+
private def self.systemtime_to_mwd(time)
139+
seconds = 3600 * time.wHour + 60 * time.wMinute + time.wSecond
140+
::Time::TZ::MonthWeekDay.new(time.wMonth.to_i8, time.wDay.to_i8, time.wDayOfWeek.to_i8, seconds)
172141
end
173142

174143
# Normalizes the names of the standard and dst zones.

src/time/tz.cr

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ module Time::TZ
3333
end
3434

3535
# `M*.*.*`: month-week-day, week 5 is last week
36+
# also used for Windows system time zones (ignoring the millisecond component)
3637
record MonthWeekDay, month : Int8, week : Int8, day : Int8, time : Int32 do
3738
def always_jan1? : Bool
3839
false
@@ -45,6 +46,10 @@ module Time::TZ
4546
def unix_date_in_year(year : Int) : Int64
4647
Time.month_week_date(year, @month.to_i32, @week.to_i32, @day.to_i32, location: Time::Location::UTC).to_unix
4748
end
49+
50+
def self.default : self
51+
new(0, 0, 0, 0)
52+
end
4853
end
4954

5055
alias POSIXTransition = Julian1 | Julian0 | MonthWeekDay
@@ -376,3 +381,61 @@ class Time::TZLocation < Time::Location
376381
{zone, {range_begin, range_end}}
377382
end
378383
end
384+
385+
# A time location capable of computing recurring time zone transitions in the
386+
# past or future using definitions from the Windows Registry.
387+
#
388+
# These locations are returned by `Time::Location.load`.
389+
class Time::WindowsLocation < Time::Location
390+
# Two sets of transition rules for times before the first transition or after
391+
# the last transition. Each corresponds to a `TZLocation`'s `@std_index`,
392+
# `@dst_index`, `@transition1`, and `@transition2` fields. If there are no
393+
# fixed transitions then the two sets are equal.
394+
@past_tz_args : {Int32, Int32, TZ::MonthWeekDay, TZ::MonthWeekDay}
395+
@future_tz_args : {Int32, Int32, TZ::MonthWeekDay, TZ::MonthWeekDay}
396+
397+
# The original Windows Registry key name for this location.
398+
@key_name : String
399+
400+
def initialize(name : String, zones : Array(Zone), @key_name, @past_tz_args, @future_tz_args = past_tz_args, transitions = [] of ZoneTransition)
401+
super(name, zones, transitions)
402+
end
403+
404+
def_equals_and_hash name, zones, transitions, @key_name
405+
406+
# :nodoc:
407+
def lookup_with_boundaries(unix_seconds : Int) : {Zone, {Int64, Int64}}
408+
case
409+
when zones.empty?
410+
{Zone::UTC, {Int64::MIN, Int64::MAX}}
411+
when transitions.empty?, unix_seconds < transitions.first.when
412+
lookup_past(unix_seconds)
413+
when unix_seconds >= transitions.last.when
414+
lookup_future(unix_seconds)
415+
else
416+
lookup_within_fixed_transitions(unix_seconds)
417+
end
418+
end
419+
420+
private def lookup_past(unix_seconds : Int) : {Zone, {Int64, Int64}}
421+
zone, range = TZ.lookup(unix_seconds, @zones, *@past_tz_args)
422+
range_begin, range_end = range
423+
424+
if first_transition = @transitions.first?
425+
range_end = {range_end, first_transition.when}.min
426+
end
427+
428+
{zone, {range_begin, range_end}}
429+
end
430+
431+
private def lookup_future(unix_seconds : Int) : {Zone, {Int64, Int64}}
432+
zone, range = TZ.lookup(unix_seconds, @zones, *@future_tz_args)
433+
range_begin, range_end = range
434+
435+
if last_transition = @transitions.last?
436+
range_begin = {range_begin, last_transition.when}.max
437+
end
438+
439+
{zone, {range_begin, range_end}}
440+
end
441+
end

0 commit comments

Comments
 (0)