Skip to content

Support Windows system time zone transitions in all years #15891

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion spec/std/time/location_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,19 @@ class Time::Location

with_system_time_zone(info) do
location = Location.load_local
location.zones.should eq [Time::Location::Zone.new("CET", 3600, false), Time::Location::Zone.new("CEST", 7200, true)]
std_zone = Time::Location::Zone.new("CET", 3600, false)
dst_zone = Time::Location::Zone.new("CEST", 7200, true)
location.zones.should eq [std_zone, dst_zone]

location.lookup(Time.utc(2000, 10, 29, 0, 59, 59)).should eq(dst_zone)
location.lookup(Time.utc(2000, 10, 29, 1, 0, 0)).should eq(std_zone)
location.lookup(Time.utc(2001, 3, 25, 0, 59, 59)).should eq(std_zone)
location.lookup(Time.utc(2001, 3, 25, 1, 0, 0)).should eq(dst_zone)

location.lookup(Time.utc(3000, 10, 26, 0, 59, 59)).should eq(dst_zone)
location.lookup(Time.utc(3000, 10, 26, 1, 0, 0)).should eq(std_zone)
location.lookup(Time.utc(3001, 3, 29, 0, 59, 59)).should eq(std_zone)
location.lookup(Time.utc(3001, 3, 29, 1, 0, 0)).should eq(dst_zone)
end
end

Expand Down
65 changes: 17 additions & 48 deletions src/crystal/system/win32/time.cr
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ module Crystal::System::Time
end

def self.load_localtime : ::Time::Location?
if LibC.GetTimeZoneInformation(out info) != LibC::TIME_ZONE_ID_INVALID
initialize_location_from_TZI(info, "Local")
if LibC.GetDynamicTimeZoneInformation(out info) != LibC::TIME_ZONE_ID_INVALID
windows_name = String.from_utf16(info.timeZoneKeyName.to_slice[0, info.timeZoneKeyName.index(0) || info.timeZoneKeyName.size])
initialize_location_from_TZI(pointerof(info).as(LibC::TIME_ZONE_INFORMATION*).value, "Local", windows_name)
end
end

Expand Down Expand Up @@ -105,70 +106,38 @@ module Crystal::System::Time
)
WindowsRegistry.get_raw(sub_handle, Std, tzi.standardName.to_slice.to_unsafe_bytes)
WindowsRegistry.get_raw(sub_handle, Dlt, tzi.daylightName.to_slice.to_unsafe_bytes)
initialize_location_from_TZI(tzi, iana_name)
initialize_location_from_TZI(tzi, iana_name, windows_name)
end
end
end

private def self.initialize_location_from_TZI(info, name)
private def self.initialize_location_from_TZI(info, name, windows_name)
stdname, dstname = normalize_zone_names(info)

if info.standardDate.wMonth == 0_u16
if info.standardDate.wMonth == 0_u16 || info.daylightDate.wMonth == 0_u16
# No DST
zone = ::Time::Location::Zone.new(stdname, info.bias * BIAS_TO_OFFSET_FACTOR, false)
return ::Time::Location.new(name, [zone])
default_tz_args = {0, 0, ::Time::TZ::MonthWeekDay.default, ::Time::TZ::MonthWeekDay.default}
return ::Time::WindowsLocation.new(name, [zone], windows_name, default_tz_args)
end

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

first_date = info.standardDate
second_date = info.daylightDate
first_index = 0_u8
second_index = 1_u8
std_index = 0
dst_index = 1
transition1 = systemtime_to_mwd(info.daylightDate)
transition2 = systemtime_to_mwd(info.standardDate)
tz_args = {std_index, dst_index, transition1, transition2}

if info.standardDate.wMonth > info.daylightDate.wMonth
first_date, second_date = second_date, first_date
first_index, second_index = second_index, first_index
end

transitions = [] of ::Time::Location::ZoneTransition

current_year = ::Time.utc.year

(current_year - 100).upto(current_year + 100) do |year|
tstamp = calculate_switchdate_in_year(year, first_date) - (zones[second_index].offset)
transitions << ::Time::Location::ZoneTransition.new(tstamp, first_index, first_index == 0, false)

tstamp = calculate_switchdate_in_year(year, second_date) - (zones[first_index].offset)
transitions << ::Time::Location::ZoneTransition.new(tstamp, second_index, second_index == 0, false)
end

::Time::Location.new(name, zones, transitions)
::Time::WindowsLocation.new(name, zones, windows_name, tz_args)
end

# Calculates the day of a DST switch in year *year* by extrapolating the date given in
# *systemtime* (for the current year).
#
# Returns the number of seconds since UNIX epoch (Jan 1 1970) in the local time zone.
private def self.calculate_switchdate_in_year(year, systemtime)
# Windows specifies daylight savings information in "day in month" format:
# wMonth is month number (1-12)
# wDayOfWeek is appropriate weekday (Sunday=0 to Saturday=6)
# wDay is week within the month (1 to 5, where 5 is last week of the month)
# wHour, wMinute and wSecond are absolute time
::Time.month_week_date(
year,
systemtime.wMonth.to_i32,
systemtime.wDay.to_i32,
systemtime.wDayOfWeek.to_i32,
systemtime.wHour.to_i32,
systemtime.wMinute.to_i32,
systemtime.wSecond.to_i32,
location: ::Time::Location::UTC,
).to_unix
private def self.systemtime_to_mwd(time)
seconds = 3600 * time.wHour + 60 * time.wMinute + time.wSecond
::Time::TZ::MonthWeekDay.new(time.wMonth.to_i8, time.wDay.to_i8, time.wDayOfWeek.to_i8, seconds)
end

# Normalizes the names of the standard and dst zones.
Expand Down
63 changes: 63 additions & 0 deletions src/time/tz.cr
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ module Time::TZ
end

# `M*.*.*`: month-week-day, week 5 is last week
# also used for Windows system time zones (ignoring the millisecond component)
record MonthWeekDay, month : Int8, week : Int8, day : Int8, time : Int32 do
def always_jan1? : Bool
false
Expand All @@ -45,6 +46,10 @@ module Time::TZ
def unix_date_in_year(year : Int) : Int64
Time.month_week_date(year, @month.to_i32, @week.to_i32, @day.to_i32, location: Time::Location::UTC).to_unix
end

def self.default : self
new(0, 0, 0, 0)
end
end

alias POSIXTransition = Julian1 | Julian0 | MonthWeekDay
Expand Down Expand Up @@ -376,3 +381,61 @@ class Time::TZLocation < Time::Location
{zone, {range_begin, range_end}}
end
end

# A time location capable of computing recurring time zone transitions in the
# past or future using definitions from the Windows Registry.
#
# These locations are returned by `Time::Location.load`.
class Time::WindowsLocation < Time::Location
# Two sets of transition rules for times before the first transition or after
# the last transition. Each corresponds to a `TZLocation`'s `@std_index`,
# `@dst_index`, `@transition1`, and `@transition2` fields. If there are no
# fixed transitions then the two sets are equal.
@past_tz_args : {Int32, Int32, TZ::MonthWeekDay, TZ::MonthWeekDay}
@future_tz_args : {Int32, Int32, TZ::MonthWeekDay, TZ::MonthWeekDay}

# The original Windows Registry key name for this location.
@key_name : String

def initialize(name : String, zones : Array(Zone), @key_name, @past_tz_args, @future_tz_args = past_tz_args, transitions = [] of ZoneTransition)
super(name, zones, transitions)
end

def_equals_and_hash name, zones, transitions, @key_name

# :nodoc:
def lookup_with_boundaries(unix_seconds : Int) : {Zone, {Int64, Int64}}
case
when zones.empty?
{Zone::UTC, {Int64::MIN, Int64::MAX}}
when transitions.empty?, unix_seconds < transitions.first.when
lookup_past(unix_seconds)
when unix_seconds >= transitions.last.when
lookup_future(unix_seconds)
else
lookup_within_fixed_transitions(unix_seconds)
end
end

private def lookup_past(unix_seconds : Int) : {Zone, {Int64, Int64}}
zone, range = TZ.lookup(unix_seconds, @zones, *@past_tz_args)
range_begin, range_end = range

if first_transition = @transitions.first?
range_end = {range_end, first_transition.when}.min
end

{zone, {range_begin, range_end}}
end

private def lookup_future(unix_seconds : Int) : {Zone, {Int64, Int64}}
zone, range = TZ.lookup(unix_seconds, @zones, *@future_tz_args)
range_begin, range_end = range

if last_transition = @transitions.last?
range_begin = {range_begin, last_transition.when}.max
end

{zone, {range_begin, range_end}}
end
end