1 require 'tzinfo'
2
3 module ActiveSupport
4 # A Time-like class that can represent a time in any time zone. Necessary because standard Ruby Time instances are
5 # limited to UTC and the system's <tt>ENV['TZ']</tt> zone.
6 #
7 # You shouldn't ever need to create a TimeWithZone instance directly via <tt>new</tt> -- instead, Rails provides the methods
8 # +local+, +parse+, +at+ and +now+ on TimeZone instances, and +in_time_zone+ on Time and DateTime instances, for a more
9 # user-friendly syntax. Examples:
10 #
11 # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)'
12 # Time.zone.local(2007, 2, 10, 15, 30, 45) # => Sat, 10 Feb 2007 15:30:45 EST -05:00
13 # Time.zone.parse('2007-02-01 15:30:45') # => Sat, 10 Feb 2007 15:30:45 EST -05:00
14 # Time.zone.at(1170361845) # => Sat, 10 Feb 2007 15:30:45 EST -05:00
15 # Time.zone.now # => Sun, 18 May 2008 13:07:55 EDT -04:00
16 # Time.utc(2007, 2, 10, 20, 30, 45).in_time_zone # => Sat, 10 Feb 2007 15:30:45 EST -05:00
17 #
18 # See TimeZone and ActiveSupport::CoreExtensions::Time::Zones for further documentation for these methods.
19 #
20 # TimeWithZone instances implement the same API as Ruby Time instances, so that Time and TimeWithZone instances are interchangable. Examples:
21 #
22 # t = Time.zone.now # => Sun, 18 May 2008 13:27:25 EDT -04:00
23 # t.hour # => 13
24 # t.dst? # => true
25 # t.utc_offset # => -14400
26 # t.zone # => "EDT"
27 # t.to_s(:rfc822) # => "Sun, 18 May 2008 13:27:25 -0400"
28 # t + 1.day # => Mon, 19 May 2008 13:27:25 EDT -04:00
29 # t.beginning_of_year # => Tue, 01 Jan 2008 00:00:00 EST -05:00
30 # t > Time.utc(1999) # => true
31 # t.is_a?(Time) # => true
32 # t.is_a?(ActiveSupport::TimeWithZone) # => true
33 class TimeWithZone
34 include Comparable
35 attr_reader :time_zone
36
37 def initialize(utc_time, time_zone, local_time = nil, period = nil)
38 @utc, @time_zone, @time = utc_time, time_zone, local_time
39 @period = @utc ? period : get_period_and_ensure_valid_local_time
40 end
41
42 # Returns a Time or DateTime instance that represents the time in +time_zone+.
43 def time
44 @time ||= period.to_local(@utc)
45 end
46
47 # Returns a Time or DateTime instance that represents the time in UTC.
48 def utc
49 @utc ||= period.to_utc(@time)
50 end
51 alias_method :comparable_time, :utc
52 alias_method :getgm, :utc
53 alias_method :getutc, :utc
54 alias_method :gmtime, :utc
55
56 # Returns the underlying TZInfo::TimezonePeriod.
57 def period
58 @period ||= time_zone.period_for_utc(@utc)
59 end
60
61 # Returns the simultaneous time in <tt>Time.zone</tt>, or the specified zone.
62 def in_time_zone(new_zone = ::Time.zone)
63 return self if time_zone == new_zone
64 utc.in_time_zone(new_zone)
65 end
66
67 # Returns a <tt>Time.local()</tt> instance of the simultaneous time in your system's <tt>ENV['TZ']</tt> zone
68 def localtime
69 utc.getlocal
70 end
71 alias_method :getlocal, :localtime
72
73 def dst?
74 period.dst?
75 end
76 alias_method :isdst, :dst?
77
78 def utc?
79 time_zone.name == 'UTC'
80 end
81 alias_method :gmt?, :utc?
82
83 def utc_offset
84 period.utc_total_offset
85 end
86 alias_method :gmt_offset, :utc_offset
87 alias_method :gmtoff, :utc_offset
88
89 def formatted_offset(colon = true, alternate_utc_string = nil)
90 utc? && alternate_utc_string || utc_offset.to_utc_offset_s(colon)
91 end
92
93 # Time uses +zone+ to display the time zone abbreviation, so we're duck-typing it.
94 def zone
95 period.zone_identifier.to_s
96 end
97
98 def inspect
99 "#{time.strftime('%a, %d %b %Y %H:%M:%S')} #{zone} #{formatted_offset}"
100 end
101
102 def xmlschema(fraction_digits = 0)
103 fraction = if fraction_digits > 0
104 ".%i" % time.usec.to_s[0, fraction_digits]
105 end
106
107 "#{time.strftime("%Y-%m-%dT%H:%M:%S")}#{fraction}#{formatted_offset(true, 'Z')}"
108 end
109 alias_method :iso8601, :xmlschema
110
111 # Coerces the date to a string for JSON encoding.
112 #
113 # ISO 8601 format is used if ActiveSupport::JSON::Encoding.use_standard_json_time_format is set.
114 #
115 # ==== Examples
116 #
117 # # With ActiveSupport::JSON::Encoding.use_standard_json_time_format = true
118 # Time.utc(2005,2,1,15,15,10).in_time_zone.to_json
119 # # => "2005-02-01T15:15:10Z"
120 #
121 # # With ActiveSupport::JSON::Encoding.use_standard_json_time_format = false
122 # Time.utc(2005,2,1,15,15,10).in_time_zone.to_json
123 # # => "2005/02/01 15:15:10 +0000"
124 def as_json(options = nil)
125 if ActiveSupport::JSON::Encoding.use_standard_json_time_format
126 xmlschema
127 else
128 %(#{time.strftime("%Y/%m/%d %H:%M:%S")} #{formatted_offset(false)})
129 end
130 end
131
132 def to_yaml(options = {})
133 if options.kind_of?(YAML::Emitter)
134 utc.to_yaml(options)
135 else
136 time.to_yaml(options).gsub('Z', formatted_offset(true, 'Z'))
137 end
138 end
139
140 def httpdate
141 utc.httpdate
142 end
143
144 def rfc2822
145 to_s(:rfc822)
146 end
147 alias_method :rfc822, :rfc2822
148
149 # <tt>:db</tt> format outputs time in UTC; all others output time in local.
150 # Uses TimeWithZone's +strftime+, so <tt>%Z</tt> and <tt>%z</tt> work correctly.
151 def to_s(format = :default)
152 return utc.to_s(format) if format == :db
153 if formatter = ::Time::DATE_FORMATS[format]
154 formatter.respond_to?(:call) ? formatter.call(self).to_s : strftime(formatter)
155 else
156 "#{time.strftime("%Y-%m-%d %H:%M:%S")} #{formatted_offset(false, 'UTC')}" # mimicking Ruby 1.9 Time#to_s format
157 end
158 end
159 alias_method :to_formatted_s, :to_s
160
161 # Replaces <tt>%Z</tt> and <tt>%z</tt> directives with +zone+ and +formatted_offset+, respectively, before passing to
162 # Time#strftime, so that zone information is correct
163 def strftime(format)
164 format = format.gsub('%Z', zone).gsub('%z', formatted_offset(false))
165 time.strftime(format)
166 end
167
168 # Use the time in UTC for comparisons.
169 def <=>(other)
170 utc <=> other
171 end
172
173 def between?(min, max)
174 utc.between?(min, max)
175 end
176
177 def past?
178 utc.past?
179 end
180
181 def today?
182 time.today?
183 end
184
185 def future?
186 utc.future?
187 end
188
189 def eql?(other)
190 utc == other
191 end
192
193 def +(other)
194 # If we're adding a Duration of variable length (i.e., years, months, days), move forward from #time,
195 # otherwise move forward from #utc, for accuracy when moving across DST boundaries
196 if duration_of_variable_length?(other)
197 method_missing(:+, other)
198 else
199 result = utc.acts_like?(:date) ? utc.since(other) : utc + other rescue utc.since(other)
200 result.in_time_zone(time_zone)
201 end
202 end
203
204 def -(other)
205 # If we're subtracting a Duration of variable length (i.e., years, months, days), move backwards from #time,
206 # otherwise move backwards #utc, for accuracy when moving across DST boundaries
207 if other.acts_like?(:time)
208 utc.to_f - other.to_f
209 elsif duration_of_variable_length?(other)
210 method_missing(:-, other)
211 else
212 result = utc.acts_like?(:date) ? utc.ago(other) : utc - other rescue utc.ago(other)
213 result.in_time_zone(time_zone)
214 end
215 end
216
217 def since(other)
218 # If we're adding a Duration of variable length (i.e., years, months, days), move forward from #time,
219 # otherwise move forward from #utc, for accuracy when moving across DST boundaries
220 if duration_of_variable_length?(other)
221 method_missing(:since, other)
222 else
223 utc.since(other).in_time_zone(time_zone)
224 end
225 end
226
227 def ago(other)
228 since(-other)
229 end
230
231 def advance(options)
232 # If we're advancing a value of variable length (i.e., years, weeks, months, days), advance from #time,
233 # otherwise advance from #utc, for accuracy when moving across DST boundaries
234 if options.values_at(:years, :weeks, :months, :days).any?
235 method_missing(:advance, options)
236 else
237 utc.advance(options).in_time_zone(time_zone)
238 end
239 end
240
241 %w(year mon month day mday wday yday hour min sec to_date).each do |method_name|
242 class_eval <<-EOV
243 def #{method_name} # def year
244 time.#{method_name} # time.year
245 end # end
246 EOV
247 end
248
249 def usec
250 time.respond_to?(:usec) ? time.usec : 0
251 end
252
253 def to_a
254 [time.sec, time.min, time.hour, time.day, time.mon, time.year, time.wday, time.yday, dst?, zone]
255 end
256
257 def to_f
258 utc.to_f
259 end
260
261 def to_i
262 utc.to_i
263 end
264 alias_method :hash, :to_i
265 alias_method :tv_sec, :to_i
266
267 # A TimeWithZone acts like a Time, so just return +self+.
268 def to_time
269 self
270 end
271
272 def to_datetime
273 utc.to_datetime.new_offset(Rational(utc_offset, 86_400))
274 end
275
276 # So that +self+ <tt>acts_like?(:time)</tt>.
277 def acts_like_time?
278 true
279 end
280
281 # Say we're a Time to thwart type checking.
282 def is_a?(klass)
283 klass == ::Time || super
284 end
285 alias_method :kind_of?, :is_a?
286
287 def freeze
288 period; utc; time # preload instance variables before freezing
289 super
290 end
291
292 def marshal_dump
293 [utc, time_zone.name, time]
294 end
295
296 def marshal_load(variables)
297 initialize(variables[0].utc, ::Time.__send__(:get_zone, variables[1]), variables[2].utc)
298 end
299
300 # Ensure proxy class responds to all methods that underlying time instance responds to.
301 def respond_to?(sym, include_priv = false)
302 # consistently respond false to acts_like?(:date), regardless of whether #time is a Time or DateTime
303 return false if sym.to_s == 'acts_like_date?'
304 super || time.respond_to?(sym, include_priv)
305 end
306
307 # Send the missing method to +time+ instance, and wrap result in a new TimeWithZone with the existing +time_zone+.
308 def method_missing(sym, *args, &block)
309 result = time.__send__(sym, *args, &block)
310 result.acts_like?(:time) ? self.class.new(nil, time_zone, result) : result
311 end
312
313 private
314 def get_period_and_ensure_valid_local_time
315 # we don't want a Time.local instance enforcing its own DST rules as well,
316 # so transfer time values to a utc constructor if necessary
317 @time = transfer_time_values_to_utc_constructor(@time) unless @time.utc?
318 begin
319 @time_zone.period_for_local(@time)
320 rescue ::TZInfo::PeriodNotFound
321 # time is in the "spring forward" hour gap, so we're moving the time forward one hour and trying again
322 @time += 1.hour
323 retry
324 end
325 end
326
327 def transfer_time_values_to_utc_constructor(time)
328 ::Time.utc_time(time.year, time.month, time.day, time.hour, time.min, time.sec, time.respond_to?(:usec) ? time.usec : 0)
329 end
330
331 def duration_of_variable_length?(obj)
332 ActiveSupport::Duration === obj && obj.parts.any? {|p| [:years, :months, :days].include? p[0] }
333 end
334 end
335 end