module CalInvite::IcalTimezone
Builds RFC 5545 VTIMEZONE components and converts UTC times to local wall-clock time for a given IANA/Olson timezone identifier, using TZInfo (already pulled in transitively via activesupport).
Only uses TZInfo APIs available in both tzinfo 1.2+ (pinned by Rails 6.0) and 2.x; the few period accessors renamed in 2.x go through {period_starts_at}, {period_ends_at} and {period_local_ends_at}.
Event validates timezone with {valid?}, so by the time a provider runs itās either UTC or a TZInfo-recognized identifier. {local_time} and {vtimezone_lines} still fail soft (return nil) for UTC or unrecognized input.
@api private
Public Instance Methods
# File lib/cal_invite/ical_timezone.rb 148 def fixed_observance_lines(period) 149 [ 150 "BEGIN:STANDARD", 151 "DTSTART:16010101T000000", 152 "TZOFFSETFROM:#{format_offset(period.utc_total_offset)}", 153 "TZOFFSETTO:#{format_offset(period.utc_total_offset)}", 154 "TZNAME:#{period.abbreviation}", 155 "END:STANDARD" 156 ] 157 end
@api private
Source
# File lib/cal_invite/ical_timezone.rb 172 def format_offset(seconds) 173 sign = seconds.negative? ? '-' : '+' 174 abs = seconds.abs 175 format('%<sign>s%<hours>02d%<minutes>02d', sign: sign, hours: abs / 3600, minutes: (abs % 3600) / 60) 176 end
@api private
Source
# File lib/cal_invite/ical_timezone.rb 54 def local_time(tzid, utc_time) 55 return nil if utc?(tzid) || tzid.nil? || tzid.to_s.strip.empty? 56 57 TZInfo::Timezone.get(tzid.to_s).utc_to_local(utc_time.utc) 58 rescue TZInfo::InvalidTimezoneIdentifier 59 nil 60 end
Converts a UTC time to local wall-clock time for the given timezone.
@param tzid [String] An IANA timezone identifier, e.g. āAmerica/New_Yorkā @param utc_time [Time] The time to convert (interpreted as UTC) @return [Time, nil] The local wall-clock time, or nil if tzid is unrecognized
# File lib/cal_invite/ical_timezone.rb 133 def observance_lines(kind, from_period, to_period) 134 transition_time = period_local_ends_at(from_period).to_time 135 136 [ 137 "BEGIN:#{kind}", 138 "DTSTART:#{transition_time.strftime('%Y%m%dT%H%M%S')}", 139 "TZOFFSETFROM:#{format_offset(from_period.utc_total_offset)}", 140 "TZOFFSETTO:#{format_offset(to_period.utc_total_offset)}", 141 "TZNAME:#{to_period.abbreviation}", 142 rrule_line(transition_time), 143 "END:#{kind}" 144 ] 145 end
@api private
Source
# File lib/cal_invite/ical_timezone.rb 110 def period_after(tz, period) 111 ends_at = period && period_ends_at(period) 112 return nil unless ends_at 113 114 tz.period_for_utc(ends_at.to_time) 115 end
@api private
Source
# File lib/cal_invite/ical_timezone.rb 102 def period_before(tz, period) 103 starts_at = period_starts_at(period) 104 return nil unless starts_at 105 106 tz.period_for_utc(starts_at.to_time - 1) 107 end
@api private
Source
# File lib/cal_invite/ical_timezone.rb 123 def period_ends_at(period) 124 period.respond_to?(:ends_at) ? period.ends_at : period.utc_end 125 end
@api private
# File lib/cal_invite/ical_timezone.rb 128 def period_local_ends_at(period) 129 period.respond_to?(:local_ends_at) ? period.local_ends_at : period.local_end 130 end
@api private
Source
# File lib/cal_invite/ical_timezone.rb 118 def period_starts_at(period) 119 period.respond_to?(:starts_at) ? period.starts_at : period.utc_start 120 end
@api private
Source
# File lib/cal_invite/ical_timezone.rb 162 def rrule_line(time) 163 wday_names = %w[SU MO TU WE TH FR SA] 164 days_in_month = Date.new(time.year, time.month, -1).day 165 nth = (time.day - 1) / 7 + 1 166 nth = -1 if time.day + 7 > days_in_month 167 168 "RRULE:FREQ=YEARLY;BYMONTH=#{time.month};BYDAY=#{nth}#{wday_names[time.wday]}" 169 end
Derives a YEARLY RRULE (e.g. ā2nd Sunday in Marchā) from a transition date.
@api private
Source
# File lib/cal_invite/ical_timezone.rb 29 def utc?(tzid) 30 tzid.to_s.upcase == 'UTC' 31 end
Whether tzid means UTC (case-insensitive āUTCā), which needs no VTIMEZONE: times are emitted as Z-suffixed UTC timestamps instead.
@param tzid [String, nil] The timezone identifier @return [Boolean]
Source
# File lib/cal_invite/ical_timezone.rb 39 def valid?(tzid) 40 return true if utc?(tzid) 41 return false if tzid.nil? || tzid.to_s.strip.empty? 42 43 TZInfo::Timezone.get(tzid.to_s) 44 true 45 rescue TZInfo::InvalidTimezoneIdentifier 46 false 47 end
Whether tzid is usable as an event timezone: UTC or a TZInfo-recognized IANA identifier (e.g. āAmerica/New_Yorkā). Raw offsets like ā+01:00ā arenāt: they have no VTIMEZONE definition.
@param tzid [String, nil] The timezone identifier @return [Boolean]
Source
# File lib/cal_invite/ical_timezone.rb 69 def vtimezone_lines(tzid) 70 return nil if utc?(tzid) || tzid.nil? || tzid.to_s.strip.empty? 71 72 tz = TZInfo::Timezone.get(tzid.to_s) 73 current = tz.period_for_utc(Time.now.utc) 74 75 if current.dst? 76 dst_period = current 77 std_period = period_before(tz, dst_period) 78 else 79 std_period = current 80 dst_period = period_after(tz, std_period) 81 dst_period = nil unless dst_period&.dst? 82 end 83 84 lines = ["BEGIN:VTIMEZONE", "TZID:#{tzid}"] 85 86 if std_period && dst_period 87 lines.concat(observance_lines('STANDARD', dst_period, std_period)) 88 lines.concat(observance_lines('DAYLIGHT', std_period, dst_period)) 89 else 90 lines.concat(fixed_observance_lines(std_period || dst_period || current)) 91 end 92 93 lines << "END:VTIMEZONE" 94 lines 95 rescue StandardError 96 # Never let an exotic/edge-case timezone break calendar generation ā 97 # worst case the VEVENT ends up without a VTIMEZONE definition. 98 nil 99 end
Builds a complete VTIMEZONE component (STANDARD/DAYLIGHT observances with RRULEs derived from the timezoneās actual transition rules) for the given timezone identifier.
@param tzid [String] An IANA timezone identifier, e.g. āAmerica/New_Yorkā @return [Array<String>, nil] iCalendar lines for the VTIMEZONE component, or nil if tzid is unrecognized, is UTC, or the component canāt be built