icalendar-rrule 0.1.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffa993b8697f4800c40fa49adeafa04b32bb312f06107db10d1ce2031e4d90da
4
- data.tar.gz: 543226f1ad33e913446693a985ca88ccb0a3996481dc93e7b8f87522e3d5af26
3
+ metadata.gz: 657bfb18be866339dcd5fa55c355864c8d5870ca96d71f83496d0e8065495c43
4
+ data.tar.gz: 37dd92456663dfb2b257db55146ca6868f492549a632a7d39f547896d3f44136
5
5
  SHA512:
6
- metadata.gz: 0e10b375f6fa9a8defe57c38294923a7edcb3ae3dd0d6ab0c433ff3f9e51f44af4cf0df84f0bcfae917856fbc5775138c66980d19b901c6bcd557ca3eb686b2c
7
- data.tar.gz: cc0bc366ef7839f451d3885ec9fa1bdaabb66c0f7c20cc4de651088dd7627fb5c8dd07606c6e88bd54d4083d237a206258f38189b1261f1672e8b1a87a2ed51a
6
+ metadata.gz: 8e6eeaec9f4174fcf7211688d07955d7a75f972ec0d9fcc5613fb194174df5a7cae03384d220b98505ff12d5ab17d7ad7243a1d778b4fb9be4c7add149da40b6
7
+ data.tar.gz: b2dcc6a4c3c6e8d2dfca0a0b96c81b477a06a3ff2e4d5cac3ca6d44d18fb7a055dba42bd3a9638e74487684ca0cd0dbb0b6c4bb75a010314ee2466d141a54166
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, master ]
6
+ pull_request:
7
+ branches: [ main, master ]
8
+ schedule:
9
+ # Runs at 04:17 on the 13th of every month to avoid the "1st of the month" peak
10
+ - cron: '17 4 13 * *'
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ # Testing against 3.1, 3.4 and the latest stable release (currently 4.0)
19
+ ruby-version: ['3.2', '3.4', 'ruby']
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Set up Ruby
25
+ uses: ruby/setup-ruby@v1
26
+ with:
27
+ ruby-version: ${{ matrix.ruby-version }}
28
+ bundler-cache: true # Runs 'bundle install' and caches gems automatically
29
+
30
+ - name: Run specs
31
+ run: bundle exec rspec
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.3.0] - 2026-02-06
6
+
7
+ ### Changed
8
+ - **BREAKING**: Minimum Ruby version increased to 3.2 (required by ActiveSupport 8.1)
9
+ - Updated timezone handling for better DST stability
10
+ - Added GitHub Actions CI pipeline
11
+
12
+
13
+ ## [0.2.0] - 2025-12-24
14
+
15
+ ### Added
16
+ - Support for tasks (VTODOs) with RRULE expansion
17
+ - New `single_timestamp?` predicate for zero-duration events/deadline-only tasks
18
+ - Comprehensive timezone extraction with multiple fallback strategies
19
+ - Better system timezone detection (ENV['TZ'], /etc/timezone, TZInfo)
20
+
21
+ ### Changed
22
+ - **BREAKING**: Events without explicit timezone now use system timezone instead of UTC
23
+ - All-day events without DTEND now correctly compute 1-day duration in date-space
24
+ - Relaxed `ice_cube` dependency to >= 0.16 (tested with 0.17.0)
25
+ - `all_day?` now returns false for tasks (only applies to events)
26
+
27
+ ### Fixed
28
+ - Timezone handling for recurring events (RRULE expansion now preserves timezone)
29
+ - Floating time interpretation (DateTime with offset 0 no longer treated as UTC)
30
+ - All-day events no longer experience timezone shift errors
31
+ - Compatibility with icalendar gem 2.12.1 (DowncasedHash handling)
32
+ - Task time handling (zero-duration tasks with only DUE)
33
+
34
+ ### Internal
35
+ - Enhanced `_extract_explicit_timezone` for better timezone detection
36
+ - Added `_dtstart_is_all_day?` helper
37
+ - Improved `_guess_system_timezone` with 5 fallback methods
38
+ - Better test coverage (145+ tests, including exotic timezones)
39
+
40
+ ## [0.1.7] - 2020-xx-xx
41
+ - Previous stable release
data/README.md CHANGED
@@ -84,6 +84,72 @@ Fri. Apr. 27. 8:30-17:00
84
84
  For a more elaborate example, please have a look at
85
85
  <https://github.com/free-creations/sk_calendar>
86
86
 
87
+ ## Configuration
88
+
89
+ ### Logging
90
+
91
+ By default, the gem logs nothing. You can enable logging for debugging timezone issues:
92
+
93
+ ```ruby
94
+ # Enable logging to STDOUT
95
+ Icalendar::Rrule.logger = Logger.new($stdout)
96
+
97
+ # Or use Rails logger
98
+ Icalendar::Rrule.logger = Rails.logger
99
+ ```
100
+
101
+ ## Time handling (start/end) and timezones
102
+
103
+ This gem represents both VEVENTs and VTODOs uniformly as *occurrences* with a
104
+ normalised `start_time` and `end_time` (both `ActiveSupport::TimeWithZone`).
105
+
106
+ ### Normalising start_time / end_time
107
+
108
+ Input components can specify time using `DTSTART`, `DTEND`, `DUE` and/or `DURATION`.
109
+ In practice these fields are often incomplete or inconsistent, therefore this gem
110
+ derives a sensible time range using the following rules:
111
+
112
+ - `start_time`
113
+ 1. If `DTSTART` is present: `start_time = DTSTART`
114
+ 2. Else if `DUE` and `DURATION` are present: `start_time = DUE - DURATION`
115
+ 3. Else if only `DUE` is present: `start_time = DUE` (deadline-only / zero-duration)
116
+ 4. Else: a null fallback is used (Unix epoch)
117
+
118
+ - `end_time`
119
+ 1. If `DUE` is present: `end_time = DUE`
120
+ 2. Else if `DTEND` is present: `end_time = DTEND`
121
+ 3. Else if `DTSTART` is present: `end_time = start_time + duration`
122
+ 4. Else: `end_time = epoch + duration`
123
+
124
+ For _all-day VEVENTs_ (`DTSTART` as DATE) without `DTEND`, `DUE` and `DURATION`, the
125
+ duration is assumed to be one day (RFC 5545).
126
+
127
+ ### Recurrence semantics (RRULE)
128
+
129
+ Recurrences are expanded in floating (wall-clock) time. The `RRULE` is unrolled
130
+ without applying offsets during expansion; timezone conversion is applied only
131
+ after occurrences have been generated.
132
+
133
+ Rationale:
134
+
135
+ - By keeping recurrence logic in wall-clock time and deferring timezone application,
136
+ this gem avoids DST-related drift, ambiguous instants and retroactive rule changes,
137
+ while remaining predictable and deterministic.
138
+
139
+ ### Timezone resolution
140
+
141
+ When mapping values to `ActiveSupport::TimeWithZone`, timezones are resolved with
142
+ the following precedence:
143
+
144
+ 1. An explicit timezone on the value itself (`TZID` / `TimeWithZone`)
145
+ 2. A timezone defined by the parent calendar (`VTIMEZONE` / calendar settings)
146
+ 3. The system timezone
147
+ 4. UTC as a final fallback
148
+
149
+ Floating date-times(i.e. `DATE-TIME` values without `TZID` and without `Z`) are interpreted as
150
+ local time and materialised in the _system time zone_.
151
+
152
+
87
153
  ## Used Libraries
88
154
 
89
155
  - [iCalendar Gem](https://github.com/icalendar/icalendar).
@@ -28,11 +28,11 @@ Gem::Specification.new do |gem_spec|
28
28
  gem_spec.executables = gem_spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
29
29
  gem_spec.require_paths = ['lib']
30
30
 
31
- gem_spec.required_ruby_version = '>= 2.5'
31
+ gem_spec.required_ruby_version = '>= 3.2'
32
32
 
33
- gem_spec.add_dependency 'activesupport', '>= 5.1'
33
+ gem_spec.add_dependency 'activesupport', '>= 8.0'
34
34
  gem_spec.add_dependency 'icalendar', '>= 2.4'
35
- gem_spec.add_dependency 'ice_cube', '>= 0.16'
35
+ gem_spec.add_dependency 'ice_cube', '>= 0.17'
36
36
 
37
37
  gem_spec.add_development_dependency 'bundler', '>= 2'
38
38
  gem_spec.add_development_dependency 'rake', '>= 12.3.3'
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'active_support/time_with_zone'
5
+ require 'active_support/values/time_zone'
6
+
7
+ module Icalendar
8
+ module Rrule
9
+ ##
10
+ # Refinements for Ruby's Time class to support iCalendar time concepts.
11
+ #
12
+ # This module provides extensions for handling:
13
+ # - Floating time (wall-clock time without timezone)
14
+ # - Timezone conversions (planned)
15
+ #
16
+ # @example
17
+ # using Icalendar::Rrule::TimeRefinements
18
+ #
19
+ # time = Time.new(2026, 1, 7, 14, 30, 0, 0)
20
+ # time.floating? # => true
21
+ #
22
+ module TimeRefinements
23
+ refine Time do
24
+ ##
25
+ # Checks if this Time object represents "floating time".
26
+ #
27
+ # Floating time is wall-clock time without timezone information,
28
+ # represented as a Time with zone == nil.
29
+ #
30
+ # In iCalendar terms, floating time is a DATETIME without TZID
31
+ # and without the "Z" suffix (e.g., "20260107T143000").
32
+ #
33
+ # This is semantically different from UTC:
34
+ # - UTC is a real timezone (the world reference)
35
+ # - Floating time means "interpret locally" (like a photo of a clock)
36
+ #
37
+ # @return [Boolean] true if this is floating time (zone is nil)
38
+ #
39
+ # @example Floating time
40
+ # Time.new(2026, 1, 1, 12, 0, 0, 0).floating? # => true
41
+ #
42
+ # @example UTC is NOT floating
43
+ # Time.utc(2026, 1, 1, 12, 0, 0).floating? # => false
44
+ #
45
+ # @example Zoned time is NOT floating
46
+ # Time.new(2026, 1, 1, 12, 0, 0, "+01:00").floating? # => false
47
+ #
48
+ def floating?
49
+ zone.nil? && utc_offset.zero?
50
+ rescue StandardError
51
+ false
52
+ end
53
+
54
+ ##
55
+ # Converts to floating time. No questions asked.
56
+ # @return [Time] floating time
57
+ def to_floating
58
+ return self if floating?
59
+ Time.new(year, month, day, hour, min, sec, 0)
60
+ end
61
+
62
+ ##
63
+ # Ensures this is floating-time, warns if conversion needed.
64
+ # @return [Time] floating time
65
+ def ensure_floating
66
+ return self if floating?
67
+
68
+ Icalendar::Rrule.logger.warn do
69
+ "[icalendar-rrule] Time should be floating but has offset: '#{inspect}' - converting"
70
+ end
71
+
72
+ to_floating
73
+ end
74
+
75
+
76
+ ##
77
+ # Embeds floating time into a specific timezone.
78
+ #
79
+ # Interprets the wall-clock components (year, month, day, hour, min, sec)
80
+ # as local time in the given timezone, creating a TimeWithZone.
81
+ #
82
+ # This is the inverse operation to `to_floating`.
83
+ #
84
+ # @param [ActiveSupport::TimeZone, String] timezone the target timezone
85
+ # @return [ActiveSupport::TimeWithZone] the time embedded in the given timezone
86
+ # @raise [ArgumentError] if called on non-floating time (to prevent accidental misuse)
87
+ #
88
+ # @example Embed floating time in Berlin timezone
89
+ # floating = Time.new(2026, 3, 30, 10, 0, 0, 0) # 10:00 floating
90
+ # berlin_time = floating.into_timezone('Europe/Berlin')
91
+ # # => 2026-03-30 10:00:00 +0200 (CEST, because DST active)
92
+ #
93
+ # @example Embed the same floating time in New York
94
+ # ny_time = floating.into_timezone('America/New_York')
95
+ # # => 2026-03-30 10:00:00 -0400 (EDT, because DST active)
96
+ #
97
+ def into_timezone(timezone)
98
+ unless floating?
99
+ raise ArgumentError, "Time::into_timezone can only be called on floating time, got: #{inspect}"
100
+ end
101
+
102
+ # Ensure we have an ActiveSupport::TimeZone object
103
+ tz = if timezone.is_a?(ActiveSupport::TimeZone)
104
+ timezone
105
+ else
106
+ ActiveSupport::TimeZone[timezone]
107
+ end
108
+
109
+ unless tz
110
+ Icalendar::Rrule.logger.warn do
111
+ "[icalendar-rrule] Invalid timezone '#{timezone.inspect}' in Time::into_timezone - falling back to UTC"
112
+ end
113
+ tz = ActiveSupport::TimeZone['UTC']
114
+ end
115
+
116
+ # Interpret wall-clock components as local time in target timezone
117
+ tz.local(year, month, day, hour, min, sec)
118
+ end
119
+
120
+ end
121
+ end
122
+ end
123
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Icalendar
4
4
  module Rrule
5
- VERSION = '0.1.7'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -6,3 +6,27 @@ require 'icalendar/scannable-calendar'
6
6
 
7
7
  require 'icalendar/rrule/version'
8
8
  require 'icalendar/rrule/occurrence'
9
+ require 'icalendar/rrule/time_refinements'
10
+ require 'logger'
11
+
12
+ module Icalendar
13
+ module Rrule
14
+ class << self
15
+ # Configurable logger for the icalendar-rrule gem.
16
+ # By default, logs nothing (Logger to /dev/null).
17
+ #
18
+ # @example Enable logging to STDOUT
19
+ # Icalendar::Rrule.logger = Logger.new($stdout)
20
+ #
21
+ # @example Use Rails logger
22
+ # Icalendar::Rrule.logger = Rails.logger
23
+ #
24
+ # @return [Logger]
25
+ attr_writer :logger
26
+
27
+ def logger
28
+ @logger ||= Logger.new(File::NULL) # default: silent
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'icalendar/rrule/time_refinements'
4
+
3
5
  module Icalendar
4
6
  ##
5
7
  # Refines the Icalendar::Calendar class by adding
@@ -14,6 +16,7 @@ module Icalendar
14
16
  # Icalendar::Calendar class
15
17
  refine Icalendar::Calendar do # rubocop:disable Metrics/BlockLength
16
18
  using Icalendar::Schedulable
19
+ using Icalendar::Rrule::TimeRefinements
17
20
  ##
18
21
  # @param[date_time] begin_time
19
22
  # @param[date_time] closing_time
@@ -49,11 +52,35 @@ module Icalendar
49
52
  end
50
53
 
51
54
  private def _occurrences_between(components, begin_time, closing_time)
55
+ # ice_cube does not support timezones well, so we have to work internally with _floating times_.
52
56
  result = []
53
- components.each do |comp|
54
- occurrences = comp.schedule.occurrences_between(begin_time, closing_time)
55
- occurrences.each do |oc|
56
- new_oc = Icalendar::Rrule::Occurrence.new(self, comp, oc.start_time, oc.end_time)
57
+ components.each do |base_component|
58
+ start_timezone = base_component._timezone_for_start
59
+ end_timezone = base_component._timezone_for_end
60
+ # we assume that that `start_timezone` and`end_timezone` are also appropriate for start and closing time of the scan.
61
+ # todo: simlify by using base_component._to_floating_time_auto
62
+ zoned_begin_time = base_component._date_to_time_with_zone(begin_time, start_timezone)
63
+ zoned_closing_time = base_component._date_to_time_with_zone(closing_time, end_timezone)
64
+
65
+ floating_begin_time = zoned_begin_time.to_time.to_floating
66
+ floating_closing_time = zoned_closing_time.to_time.to_floating
67
+
68
+ ice_cube_occurrences = base_component.schedule.occurrences_between(floating_begin_time, floating_closing_time)
69
+
70
+ ice_cube_occurrences.each do |ice_oc|
71
+ # retrieve start and end for this ice_cube occurrence and convert into the
72
+ # target timezones
73
+ ice_comp_start_time = ice_oc.start_time
74
+ # we assert that ice_comp_start_time is floating here.
75
+ fail "expected floating-time for ice_start_time" unless ice_comp_start_time.floating?
76
+ start_tz = ice_comp_start_time.into_timezone(start_timezone)
77
+
78
+ ice_end_time = ice_oc.end_time
79
+ # we assert that ice_end_time is floating here.
80
+ fail "expected floating-time for ice_end_time" unless ice_end_time.floating?
81
+ end_tz = ice_end_time.into_timezone(end_timezone)
82
+
83
+ new_oc = Icalendar::Rrule::Occurrence.new(self, base_component, start_tz, end_tz)
57
84
  result << new_oc
58
85
  end
59
86
  end
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'ice_cube'
4
+ require 'active_support/time'
4
5
  require 'active_support/time_with_zone'
6
+ require 'active_support/values/time_zone'
7
+ require 'date'
8
+ require 'time'
5
9
 
6
10
  module Icalendar
7
11
  ##
@@ -44,11 +48,11 @@ module Icalendar
44
48
  NULL_TIME = 0
45
49
 
46
50
  # the number of seconds in a minute
47
- SEC_MIN = 60
51
+ SEC_MIN = 60
48
52
  # the number of seconds in an hour
49
53
  SEC_HOUR = 60 * SEC_MIN
50
54
  # the number of seconds in a day
51
- SEC_DAY = 24 * SEC_HOUR
55
+ SEC_DAY = 24 * SEC_HOUR
52
56
  # the number of seconds in a week
53
57
  SEC_WEEK = 7 * SEC_DAY
54
58
  ##
@@ -93,8 +97,15 @@ module Icalendar
93
97
  end
94
98
 
95
99
  ##
96
- # @return [Integer] the number of seconds this task will last.
97
- # If no duration for this task is specified, this function returns zero.
100
+ # Returns the explicit duration from DURATION property or guessed duration.
101
+ #
102
+ # WARNING: This does NOT compute the actual duration between start_time and end_time!
103
+ # For events with DTEND but no DURATION property, this returns 0 or the guessed duration,
104
+ # even though the actual duration may be longer.
105
+ #
106
+ # To get the actual duration, use: (end_time.to_i - start_time.to_i)
107
+ #
108
+ # @return [Integer] explicit duration in seconds, or 0 if not specified
98
109
  # @api private
99
110
  def _duration_seconds # rubocop:disable Metrics/AbcSize
100
111
  return _guessed_duration unless _duration
@@ -103,6 +114,25 @@ module Icalendar
103
114
  d.seconds + (d.minutes * SEC_MIN) + (d.hours * SEC_HOUR) + (d.days * SEC_DAY) + (d.weeks * SEC_WEEK)
104
115
  end
105
116
 
117
+ # Check if dtstart looks like an all-day event (starts at midnight)
118
+ # This is used internally to determine if we should apply the 1-day duration rule
119
+ # @return [Boolean] true if dtstart is a Date or starts at midnight
120
+ # @api private
121
+ def _dtstart_is_all_day?
122
+ # Only apply the 1-day rule to Events, not Tasks
123
+ return false unless self.is_a?(Icalendar::Event)
124
+
125
+ return true if _dtstart.is_a?(Icalendar::Values::Date)
126
+
127
+ # If it's a DateTime, check if it's at midnight (00:00:00)
128
+ if _dtstart.respond_to?(:to_time) || _dtstart.respond_to?(:to_datetime)
129
+ time = _to_time_with_zone(_dtstart)
130
+ return time == time.beginning_of_day
131
+ end
132
+
133
+ false
134
+ end
135
+
106
136
  ##
107
137
  # Make an educated guess how long this event might last according to the following definition from RFC 5545:
108
138
  #
@@ -114,7 +144,7 @@ module Icalendar
114
144
  # @return [Integer] the number of seconds this task might last.
115
145
  # @api private
116
146
  def _guessed_duration
117
- if _dtstart.is_a?(Icalendar::Values::Date) && _dtend.nil? && _duration.nil? && _due.nil?
147
+ if _dtstart_is_all_day? && _dtend.nil? && _duration.nil? && _due.nil?
118
148
  SEC_DAY
119
149
  else
120
150
  0
@@ -127,8 +157,11 @@ module Icalendar
127
157
  def start_time
128
158
  if _dtstart
129
159
  _to_time_with_zone(_dtstart)
130
- elsif _due
160
+ elsif _due && _duration_seconds > 0
131
161
  _to_time_with_zone(_due.to_i - _duration_seconds)
162
+ elsif _due
163
+ # Task with only DUE, no duration: start == end (zero-duration/deadline-only)
164
+ _to_time_with_zone(_due)
132
165
  else
133
166
  _to_time_with_zone(NULL_TIME)
134
167
  end
@@ -143,17 +176,51 @@ module Icalendar
143
176
  elsif _dtend
144
177
  _to_time_with_zone(_dtend)
145
178
  elsif _dtstart
146
- _to_time_with_zone(_dtstart.to_i + _duration_seconds)
179
+ # Special handling for all-day events without explicit end
180
+ if _dtstart_is_all_day? && _dtend.nil?
181
+ # Stay in date space: add days to the date, not seconds to timestamp
182
+ start_date = _dtstart_all_day_event_as_date
183
+ end_date = start_date + (_duration_seconds / SEC_DAY).days
184
+ _date_to_time_with_zone(end_date, component_timezone)
185
+ else
186
+ _to_time_with_zone(start_time.to_i + _duration_seconds)
187
+ end
147
188
  else
148
189
  _to_time_with_zone(NULL_TIME + _duration_seconds)
149
190
  end
150
191
  end
151
192
 
193
+ # Extract the date component from dtstart, assuming it's an all-day event
194
+ # @return [Date]
195
+ # @api private
196
+ def _dtstart_all_day_event_as_date
197
+ raise ArgumentError, "dtstart is not an all-day event" unless _dtstart_is_all_day?
198
+
199
+ if _dtstart.is_a?(Icalendar::Values::Date)
200
+ _dtstart.to_date
201
+ elsif _dtstart.respond_to?(:to_date)
202
+ _dtstart.to_date
203
+ else
204
+ # Fallback: convert via TimeWithZone
205
+ time_with_zone = _to_time_with_zone(_dtstart)
206
+ raise ArgumentError, "Cannot convert dtstart to date" unless time_with_zone.respond_to?(:to_date)
207
+ time_with_zone.to_date
208
+ end
209
+ end
210
+
152
211
  ##
153
212
  # Heuristic to determine whether the event is scheduled
154
- # for a date without precising the exact time of day.
155
- # @return [Boolean] true if the component is scheduled for a date, false otherwise.
213
+ # for a date without specifying the exact time of day.
214
+ #
215
+ # Note: This method always returns false for tasks (VTODOs),
216
+ # as the all-day concept only applies to events (VEVENTs).
217
+ #
218
+ # @return [Boolean] true if the component is an Event scheduled for an entire day,
219
+ # false for tasks or timed events
156
220
  def all_day?
221
+ # todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
222
+ return false unless self.is_a?(Icalendar::Event)
223
+
157
224
  _dtstart.is_a?(Icalendar::Values::Date) ||
158
225
  (start_time == start_time.beginning_of_day && end_time == end_time.beginning_of_day)
159
226
  end
@@ -165,7 +232,21 @@ module Icalendar
165
232
  end
166
233
 
167
234
  ##
168
- # Make sure, that we can always query for a _rrule_ array.
235
+ # Indicates whether this component represents a single point in time
236
+ # rather than a time range. Common for:
237
+ # - Open-ended events (e.g., concert start time without known end)
238
+ # - Tasks with only a deadline (no start time specified)
239
+ #
240
+ # @return [Boolean] true if the component has no duration
241
+ def single_timestamp?
242
+ # todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
243
+ return false if start_time.nil? || end_time.nil? # <--- ???? are never nil ????
244
+ # Compare at second precision (ignore potential microsecond differences)
245
+ start_time.to_i == end_time.to_i
246
+ end
247
+
248
+ ##
249
+ # Make sure that we can always query for a _rrule_ array.
169
250
  # @return [array] an array of _ical repeat-rules_ (or an empty array
170
251
  # if no repeat-rules are defined for this component).
171
252
  # @api private
@@ -176,9 +257,10 @@ module Icalendar
176
257
  end
177
258
 
178
259
  ##
179
- # Make sure, that we can always query for an _exdate_ array.
180
- # @return [array<ActiveSupport::TimeWithZone>] an array of _ical exdates_ (or an empty array
181
- # if no repeat-rules are defined for this component).
260
+ # Make sure that we can always query for an `_exdate` array.
261
+ # @return [Array] an array of _ical exdates_ in their original format
262
+ # (may be Icalendar::Values::DateTime, Icalendar::Values::Date, or other date/time types).
263
+ # Returns an empty array if no exdates are defined or an error occurs.
182
264
  # @api private
183
265
  def _exdates
184
266
  Array(exdate).flatten
@@ -236,7 +318,7 @@ module Icalendar
236
318
  ##
237
319
  # Like the for _exdates, also for these dates do not schedule recurrence items.
238
320
  #
239
- # @return [array<ActiveSupport::TimeWithZone>] an array of dates.
321
+ # @return [Array<Object>] an array of dates.
240
322
  # @api private
241
323
  def _overwritten_dates
242
324
  result = []
@@ -250,7 +332,7 @@ module Icalendar
250
332
  end
251
333
 
252
334
  ##
253
- # Make sure, that we can always query for an rdate(Recurrence Date) array.
335
+ # Make sure that we can always query for a rdate(Recurrence Date) array.
254
336
  # @return [array] an array of _ical rdates_ (or an empty array
255
337
  # if no repeat-rules are defined for this component).
256
338
  # @api private
@@ -264,29 +346,110 @@ module Icalendar
264
346
  # Creates a schedule for this event
265
347
  # @return [IceCube::Schedule]
266
348
  def schedule # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
267
- schedule = IceCube::Schedule.new
268
- schedule.start_time = start_time
269
- schedule.end_time = end_time
349
+ # Calculate the duration of this base event in seconds
350
+ duration_seconds = (end_time.to_i - start_time.to_i) # Integer seconds
351
+
352
+ # Create a schedule with start_time and duration
353
+ # Convert to floating Time for IceCube compatibility
354
+ schedule = IceCube::Schedule.new(_to_floating_time(start_time, _timezone_for_start), duration: duration_seconds)
355
+
270
356
  _rrules.each do |rrule|
271
- ice_cube_recurrence_rule = IceCube::Rule.from_ical(rrule)
357
+ # Convert RRULE's UNTIL time (if present) to floating time in event's timezone
358
+ normalized_rrule = _normalize_rrule_until(rrule, _timezone_for_start)
359
+ ice_cube_recurrence_rule = IceCube::Rule.from_ical(normalized_rrule)
272
360
  schedule.add_recurrence_rule(ice_cube_recurrence_rule)
273
361
  end
274
362
 
275
- _exdates.each do |time|
276
- schedule.add_exception_time(_to_time_with_zone(time))
363
+ _exdates.each do |ex_time|
364
+ schedule.add_exception_time(_to_floating_time(ex_time, _timezone_for_start))
277
365
  end
278
366
 
279
- _overwritten_dates.each do |time|
280
- schedule.add_exception_time(_to_time_with_zone(time))
367
+ _overwritten_dates.each do |overwritten_time|
368
+ schedule.add_exception_time(_to_floating_time(overwritten_time, _timezone_for_start))
281
369
  end
282
370
 
283
371
  rdates = _rdates
284
- rdates.each do |time|
285
- schedule.add_recurrence_time(_to_time_with_zone(time))
372
+ rdates.each do |recurrence_time|
373
+ schedule.add_recurrence_time(_to_floating_time(recurrence_time, _timezone_for_start))
286
374
  end
287
375
  schedule
288
376
  end
289
377
 
378
+ ##
379
+ # Normalizes an RRULE string by converting UNTIL times to a format IceCube can handle.
380
+ #
381
+ # ## The IceCube System-Timezone Problem
382
+ #
383
+ # IceCube has a critical flaw: it interprets floating times (without Z suffix) in the
384
+ # **system timezone** at runtime, making PRODUCTION BEHAVIOR dependent on the server's
385
+ # timezone configuration. This isn't just a test problem - the same calendar file would
386
+ # produce different occurrences on different servers!
387
+ #
388
+ # Example with `UNTIL=20180609T190000` (floating, should be 19:00 in event's timezone):
389
+ # - Server in Detroit (UTC-4): IceCube interprets as 19:00 Detroit → 23:00 UTC ❌
390
+ # - Server in Brisbane (UTC+10): IceCube interprets as 19:00 Brisbane → 09:00 UTC ❌
391
+ # - Server in Berlin (UTC+2): IceCube interprets as 19:00 Berlin → 17:00 UTC ✅ (by luck!)
392
+ #
393
+ # This means the SAME iCalendar file would stop repeating at different times depending
394
+ # on WHERE the application is deployed. This is completely unacceptable for a calendar
395
+ # library that's supposed to provide consistent, predictable behavior.
396
+ #
397
+ # ## The Hack: Always append Z suffix
398
+ #
399
+ # IceCube treats times with Z suffix as absolute values and skips timezone conversion:
400
+ # - `UNTIL=20180609T190000Z` → IceCube uses 19:00 directly, no system-TZ applied ✅
401
+ #
402
+ # This is **technically incorrect** (Z means UTC in RFC 5545, not floating time), but it's
403
+ # the only way to get consistent, server-timezone-independent behavior. We convert times
404
+ # to the event's timezone first, then append Z to "lock in" that wall-clock time and
405
+ # prevent IceCube from applying system-TZ conversion.
406
+ #
407
+ # Without this hack, deployments would behave differently based on server timezone,
408
+ # breaking calendar consistency across environments.
409
+ #
410
+ # @param [String] rrule_string the RRULE string (e.g., "FREQ=WEEKLY;UNTIL=20180615T120000Z")
411
+ # @param [ActiveSupport::TimeZone] target_timezone the event's timezone
412
+ # @return [String] the normalized RRULE string with UNTIL time + Z suffix
413
+ # @api private
414
+ #
415
+ # @example RFC 5545 floating time gets Z appended (the hack!)
416
+ # rrule = "FREQ=WEEKLY;UNTIL=20180609T190000"
417
+ # _normalize_rrule_until(rrule, 'Europe/Berlin')
418
+ # # => "FREQ=WEEKLY;UNTIL=20180609T190000Z" (Z added to prevent system-TZ interpretation)
419
+ #
420
+ # @example UTC UNTIL gets converted to event timezone, then Z appended
421
+ # rrule = "FREQ=WEEKLY;UNTIL=20180609T170000Z" # 17:00 UTC
422
+ # _normalize_rrule_until(rrule, 'Europe/Berlin') # Event in Berlin (UTC+2)
423
+ # # => "FREQ=WEEKLY;UNTIL=20180609T190000Z" (17:00 UTC → 19:00 Berlin, Z prevents conversion)
424
+ #
425
+ def _normalize_rrule_until(rrule_string, target_timezone)
426
+ # Match UNTIL with UTC time (Z suffix) or with explicit time
427
+ # Matches: UNTIL=20180615T120000Z or UNTIL=20180615T120000
428
+ rrule_string.gsub(/UNTIL=(\d{8}T\d{6})(Z?)/) do |_match|
429
+ timestamp_str = $1
430
+ is_utc = $2 == 'Z'
431
+
432
+ # RFC 5545 floating time (no Z): pass through unchanged, but add Z to prevent
433
+ # IceCube from interpreting it in system-TZ
434
+ next "UNTIL=#{timestamp_str}Z" unless is_utc
435
+
436
+ # Already UTC with Z - convert to floating in target timezone but add Z to prevent
437
+ # IceCube from interpreting it in system-TZ
438
+ year = timestamp_str[0..3].to_i
439
+ month = timestamp_str[4..5].to_i
440
+ day = timestamp_str[6..7].to_i
441
+ hour = timestamp_str[9..10].to_i
442
+ minute = timestamp_str[11..12].to_i
443
+ second = timestamp_str[13..14].to_i
444
+
445
+ time_obj = Time.utc(year, month, day, hour, minute, second)
446
+ floating_until = _to_floating_time(time_obj, target_timezone)
447
+
448
+ # Format with Z to prevent IceCube system-TZ interpretation
449
+ "UNTIL=#{floating_until.strftime('%Y%m%dT%H%M%S')}Z"
450
+ end
451
+ end
452
+
290
453
  ##
291
454
  # Transform the given object into an object of type `ActiveSupport::TimeWithZone`.
292
455
  #
@@ -299,6 +462,9 @@ module Icalendar
299
462
  #
300
463
  # rubocop:disable Metrics/MethodLength,Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
301
464
  def _to_time_with_zone(date_time, timezone = nil)
465
+ # Try to extract timezone from the date_time parameter first
466
+ timezone ||= _extract_explicit_timezone(date_time)
467
+ # Fall back to component timezone if no timezone could be extracted
302
468
  timezone ||= component_timezone
303
469
 
304
470
  # For Icalendar::Values::DateTime, we can extract the ical value. Which probably is already what we want.
@@ -317,6 +483,19 @@ module Icalendar
317
483
  return date_time_value.in_time_zone(timezone)
318
484
 
319
485
  elsif date_time_value.is_a?(DateTime)
486
+ # If DateTime has offset 0, treat it as "floating time" in the target timezone
487
+ # rather than converting from UTC
488
+ if date_time_value.offset.zero?
489
+ return timezone.local(
490
+ date_time_value.year,
491
+ date_time_value.month,
492
+ date_time_value.day,
493
+ date_time_value.hour,
494
+ date_time_value.min,
495
+ date_time_value.sec
496
+ )
497
+ end
498
+ # DateTime with explicit non-zero offset: convert to target timezone
320
499
  return date_time_value.in_time_zone(timezone)
321
500
 
322
501
  elsif date_time_value.is_a?(Icalendar::Values::Date)
@@ -325,6 +504,10 @@ module Icalendar
325
504
  elsif date_time_value.is_a?(Date)
326
505
  return _date_to_time_with_zone(date_time_value, timezone)
327
506
 
507
+ elsif date_time_value.is_a?(Time)
508
+ # Treat Ruby Time as quasi-floating: preserve wall-clock time, embed in the target timezone
509
+ return _embed_in_timezone_preserving_wall_clock(date_time_value, timezone)
510
+
328
511
  elsif date_time_value.respond_to?(:to_time)
329
512
  return timezone.at(date_time_value.to_time)
330
513
 
@@ -336,6 +519,7 @@ module Icalendar
336
519
  # Oops, the given object is unusable, we'll give back the NULL_DATE
337
520
  timezone.at(NULL_TIME)
338
521
  end
522
+
339
523
  # rubocop:enable Metrics/MethodLength,Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
340
524
 
341
525
  ##
@@ -348,24 +532,190 @@ module Icalendar
348
532
  timezone.local(d.year, d.month, d.day)
349
533
  end
350
534
 
535
+ ##
536
+ # Converts any time to floating time, CONVERTING to the target timezone first.
537
+ #
538
+ # Use this when you want the wall-clock time in a DIFFERENT timezone.
539
+ # Example: EXDATE:20260330T080000Z (UTC) with event in Berlin → 10:00 floating (Berlin wall-clock)
540
+ #
541
+ # @param [Object] date_or_time any object representing a time (Icalendar::Values::DateTime,
542
+ # ActiveSupport::TimeWithZone, Date, Time, Integer, etc.)
543
+ # @param [ActiveSupport::TimeZone,String] target_tz the timezone to interpret the time in
544
+ # before converting to floating time.
545
+ # @return [Time] a Ruby Time object with UTC offset 0 (floating time)
546
+ # @api private
547
+ #
548
+ # @example Convert a UTC timestamp to floating time in Berlin
549
+ # # UTC: 2018-01-01 15:00 UTC → Berlin: 2018-01-01 16:00 CET
550
+ # floating = _to_floating_time(utc_time, ActiveSupport::TimeZone['Europe/Berlin'])
551
+ # # => 2018-01-01 16:00:00 +0000 (floating)
552
+ def _to_floating_time(date_or_time, target_tz)
553
+
554
+ if date_or_time.is_a?(Date)
555
+ return Time.new(date_or_time.year, date_or_time.month, date_or_time.day, 0, 0, 0, 0)
556
+ end
557
+
558
+ if date_or_time.is_a?(Icalendar::Values::Date)
559
+ return Time.new(date_or_time.year, date_or_time.month, date_or_time.day, 0, 0, 0, 0)
560
+ end
561
+
562
+ active_target_tz = _ensure_active_timezone(target_tz)
563
+
564
+ # Convert to TimeWithZone in the target timezone first
565
+ time_with_zone = _embed_in_timezone_preserving_moment(date_or_time, active_target_tz)
566
+
567
+ # Extract wall-clock components and create floating time (offset 0)
568
+ Time.new(
569
+ time_with_zone.year,
570
+ time_with_zone.month,
571
+ time_with_zone.day,
572
+ time_with_zone.hour,
573
+ time_with_zone.min,
574
+ time_with_zone.sec,
575
+ 0 # UTC offset 0 = floating time
576
+ )
577
+ end
578
+
579
+ ##
580
+ # Embeds a Ruby Time object into a timezone, preserving wall-clock time.
581
+ #
582
+ # This treats the Time as "quasi-floating" - we take its wall-clock components
583
+ # (year, month, day, hour, minute, second) and interpret them in the target timezone,
584
+ # ignoring the original offset. The UTC moment will change according to the
585
+ # timezone offset difference.
586
+ #
587
+ # A warning is logged if the Time's original offset doesn't match the target
588
+ # timezone's offset (indicating potential user confusion about timezones).
589
+ #
590
+ # Use this when the Time represents a wall-clock reading (e.g., from user input
591
+ # or an iCalendar floating time) that should be interpreted in a specific timezone.
592
+ #
593
+ # @example
594
+ # local_time = Time.new(2026, 1, 15, 12, 0, 0, '+05:00') # 12:00 somewhere
595
+ # berlin = ActiveSupport::TimeZone['Europe/Berlin']
596
+ # result = _embed_in_timezone_preserving_wall_clock(local_time, berlin)
597
+ # # => 2026-01-15 12:00:00 +0100 (same wall-clock, different moment)
598
+ #
599
+ # @param [Time] time_value a Ruby Time object
600
+ # @param [ActiveSupport::TimeZone] timezone the target timezone
601
+ # @return [ActiveSupport::TimeWithZone] the time embedded in the target timezone
602
+ # @api private
603
+ # @see _embed_in_timezone_preserving_moment for the inverse operation
604
+ def _embed_in_timezone_preserving_wall_clock(time_value, timezone)
605
+ # Create the result in target timezone with same wall-clock time
606
+ result = timezone.local(
607
+ time_value.year,
608
+ time_value.month,
609
+ time_value.day,
610
+ time_value.hour,
611
+ time_value.min,
612
+ time_value.sec
613
+ )
614
+
615
+ # Warn if the original offset doesn't match the target timezone
616
+ if time_value.utc_offset != result.utc_offset
617
+ Icalendar::Rrule.logger.warn do
618
+ "[icalendar-rrule] Time offset (#{time_value.utc_offset}s) differs from target timezone " \
619
+ "#{timezone.name} - forcing to #{result.utc_offset}s"
620
+ end
621
+ end
622
+
623
+ result
624
+ end
625
+
626
+ ##
627
+ # Embeds a Ruby Time object into a timezone, preserving the UTC moment.
628
+ #
629
+ # Converts the Time to the target timezone while keeping the same instant in time.
630
+ # The wall-clock time will change according to the timezone offset difference.
631
+ #
632
+ # Use this when the Time represents a specific moment in time (e.g., from a database
633
+ # timestamp or API response) that should be displayed in a different timezone.
634
+ #
635
+ # @example
636
+ # utc_time = Time.utc(2026, 1, 15, 12, 0, 0) # 12:00 UTC
637
+ # berlin = ActiveSupport::TimeZone['Europe/Berlin']
638
+ # result = _embed_in_timezone_preserving_moment(utc_time, berlin)
639
+ # # => 2026-01-15 13:00:00 +0100 (same moment, different wall-clock)
640
+ #
641
+ # @param [Time] time_value a Ruby Time object
642
+ # @param [ActiveSupport::TimeZone] timezone the target timezone
643
+ # @return [ActiveSupport::TimeWithZone] the time in the target timezone
644
+ # @api private
645
+ # @see _embed_time_preserving_wall_clock for the inverse operation
646
+ def _embed_in_timezone_preserving_moment(time_value, timezone)
647
+ if time_value.respond_to?(:to_time)
648
+ timezone.at(time_value.to_time.getutc)
649
+ elsif time_value.is_a?(Integer)
650
+ timezone.at(time_value)
651
+ else
652
+ throw ArgumentError, "Unexpected time value: #{time_value}"
653
+ end
654
+ end
655
+
656
+ ##
657
+ # Ensures the given `tz` is either an ActiveSupport::TimeZone object or the name of an existing timezone.
658
+ #
659
+ # If the given timezone name is invalid, logs a warning and returns UTC.
660
+ #
661
+ # @param [ActiveSupport::TimeZone,String] tz a timezone object or timezone name string
662
+ # @return [ActiveSupport::TimeZone] the given timezone object, the timezone with the given name,
663
+ # or UTC if the given timezone-name is invalid
664
+ # @api private
665
+ def _ensure_active_timezone(tz)
666
+ # If already a TimeZone object, return it
667
+ return tz if tz.is_a?(ActiveSupport::TimeZone)
668
+
669
+ # Try to lookup by name/value
670
+ result = ActiveSupport::TimeZone[tz]
671
+ return result if result
672
+
673
+ # Invalid timezone - log warning and return UTC
674
+ Icalendar::Rrule.logger.warn do
675
+ "[icalendar-rrule] Invalid timezone '#{tz.inspect}' - falling back to UTC"
676
+ end
677
+
678
+ # Fallback to UTC
679
+ # Use offset 0 as fallback if even 'UTC' lookup fails (should never happen)
680
+ ActiveSupport::TimeZone['UTC'] || ActiveSupport::TimeZone[0]
681
+ end
682
+
351
683
  ##
352
684
  # Heuristic to determine the best timezone that shall be used in this component.
353
685
  # @return [ActiveSupport::TimeZone] the unique timezone used in this component
686
+ # @deprecated there is no unique timezone for a component. Use `timezone_for_start` or `timezone_for_end` instead.
354
687
  def component_timezone
355
688
  # let's try sequentially, the first non-nil wins.
356
- timezone ||= _extract_timezone(_dtend)
357
- timezone ||= _extract_timezone(_dtstart)
358
- timezone ||= _extract_timezone(_due)
689
+ timezone ||= _extract_explicit_timezone(_dtend)
690
+ timezone ||= _extract_explicit_timezone(_dtstart)
691
+ timezone ||= _extract_explicit_timezone(_due)
359
692
  timezone ||= _extract_calendar_timezone
693
+ timezone ||= _guess_system_timezone
360
694
 
361
695
  # as a last resort we'll use the Coordinated Universal Time (UTC).
362
696
  timezone || ActiveSupport::TimeZone['UTC']
363
697
  end
364
698
 
699
+ ##
700
+ # Determine the timezone that shall be used for `start_time` this component
701
+ # @return [ActiveSupport::TimeZone] the unique timezone used for the start_time of this component
702
+ def _timezone_for_start
703
+ # todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
704
+ start_time.time_zone
705
+ end
706
+
707
+ ##
708
+ # Determine the timezone that shall be used for `end_time` this component
709
+ # @return [ActiveSupport::TimeZone] the unique timezone used for the end_time of this component
710
+ def _timezone_for_end
711
+ # todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
712
+ end_time.time_zone
713
+ end
714
+
365
715
  ##
366
716
  # Try to determine this components time zone by inspecting the parents calendar.
367
717
  # @return[ActiveSupport::TimeZone, nil] the first valid timezone found in the
368
- # parent calender or nil if none could be found.
718
+ # parent calendar or nil if none could be found.
369
719
  #
370
720
  # rubocop:disable Metrics/CyclomaticComplexity
371
721
  def _extract_calendar_timezone
@@ -373,7 +723,6 @@ module Icalendar
373
723
  return nil unless parent.is_a?(Icalendar::Calendar)
374
724
  calendar_timezones = parent.timezones
375
725
  calendar_timezones.each do |tz|
376
- break unless tz.valid?(true)
377
726
  ugly_tzid = tz.tzid
378
727
  break unless ugly_tzid
379
728
  tzid = Array(ugly_tzid).first.to_s.gsub(/^(["'])|(["'])$/, '')
@@ -384,15 +733,26 @@ module Icalendar
384
733
  rescue StandardError
385
734
  nil
386
735
  end
736
+
387
737
  # rubocop:enable Metrics/CyclomaticComplexity
388
738
 
389
739
  ##
390
- # Get the timezone from the given object trying different methods to find an indication in the object.
391
- # @param [Object] date_time an object from which we shall determine the time zone.
392
- # @return [ActiveSupport::TimeZone, nil] the timezone used by the parameter or nil if no timezone has been set.
740
+ # Extracts an explicitly set timezone from the given object.
741
+ #
742
+ # This method only returns a timezone if it was explicitly specified through:
743
+ # - An iCalendar TZID parameter (e.g., tzid: 'Europe/Berlin')
744
+ # - An existing ActiveSupport::TimeWithZone object
745
+ # - A wrapped value that is already a TimeWithZone
746
+ #
747
+ # Unlike _guess_timezone_from_offset, this method does NOT guess or infer
748
+ # timezones from UTC offsets. It returns nil if no explicit timezone is found.
749
+ #
750
+ # @param date_time [Object] an object from which to extract the timezone.
751
+ # Typically, an Icalendar::Value, Time, DateTime, or ActiveSupport::TimeWithZone.
752
+ # @return [ActiveSupport::TimeZone, nil] the explicitly set timezone, or nil if none found.
393
753
  # @api private
394
- def _extract_timezone(date_time)
395
- timezone ||= _extract_ical_time_zone(date_time) # try with ical parameter
754
+ def _extract_explicit_timezone(date_time)
755
+ timezone ||= _extract_ical_time_zone(date_time) # try with ical TZID parameter (most specific)
396
756
  timezone ||= _extract_act_sup_timezone(date_time) # is the given value already ActiveSupport::TimeWithZone?
397
757
  timezone || _extract_value_time_zone(date_time) # is the ical.value of type ActiveSupport::TimeWithZone?
398
758
  end
@@ -419,6 +779,52 @@ module Icalendar
419
779
  ical_value.value.time_zone
420
780
  end
421
781
 
782
+ ##
783
+ # Guesses the corresponding ActiveSupport timezone from a given time object's UTC offset.
784
+ # This method extracts the UTC offset from objects that respond to :utc_offset
785
+ # (such as Time, DateTime, or their wrapped values in Icalendar::Values)
786
+ # and matches it to an equivalent ActiveSupport::TimeZone.
787
+ #
788
+ # Note: Since multiple timezones can share the same UTC offset (e.g., Berlin,
789
+ # Amsterdam, Paris all use +01:00), this method returns an arbitrary timezone
790
+ # with the matching offset - hence "guess" rather than "extract".
791
+ #
792
+ # If the input does not respond to :utc_offset or an error occurs during processing,
793
+ # the method returns nil.
794
+ #
795
+ # @param date_time [Object] the object to extract the UTC offset from.
796
+ # Should respond to :utc_offset (e.g., Time, DateTime, Icalendar::Values::DateTime).
797
+ # @return [ActiveSupport::TimeZone, nil] an ActiveSupport::TimeZone matching the UTC offset,
798
+ # or nil if no match is found or an error occurs.
799
+ # @deprecated makes little sense and can be avoided.
800
+ # @api private
801
+ def _guess_timezone_from_offset(date_time)
802
+ # Extract value from Icalendar::Values::DateTime if needed
803
+ value = date_time.is_a?(Icalendar::Value) && date_time.respond_to?(:value) ? date_time.value : date_time
804
+
805
+ return nil unless value.respond_to?(:utc_offset)
806
+
807
+ # Get the timezone offset from the Time or DateTime object
808
+ offset_seconds = value.utc_offset
809
+ return nil unless offset_seconds.is_a?(Integer)
810
+
811
+ # First try: check if the system's default timezone matches the offset
812
+ system_tz = _guess_system_timezone
813
+
814
+ # Return `system timezone` if it matches the offset
815
+ return system_tz if system_tz && system_tz.now.utc_offset == offset_seconds
816
+
817
+ # Fallback: find any timezone matching the offset
818
+ # For offset 0, always use UTC to avoid ambiguous timezones with DST
819
+ if offset_seconds.zero?
820
+ ActiveSupport::TimeZone['UTC']
821
+ else
822
+ ActiveSupport::TimeZone[offset_seconds]
823
+ end
824
+ rescue StandardError
825
+ nil
826
+ end
827
+
422
828
  ##
423
829
  # Get the timezone from the given object, assuming it can be extracted from ical params.
424
830
  # @param [Icalendar::Value] ical_value an ical value that (probably) supports a time zone identifier.
@@ -427,11 +833,73 @@ module Icalendar
427
833
  def _extract_ical_time_zone(ical_value)
428
834
  return nil unless ical_value.is_a?(Icalendar::Value)
429
835
  return nil unless ical_value.respond_to?(:ical_params)
430
- ugly_tzid = ical_value.ical_params.fetch('tzid', nil)
836
+
837
+ ical_params = ical_value.ical_params
838
+ return nil unless ical_params
839
+
840
+ ugly_tzid = ical_params['tzid'] || ical_params[:tzid] || ical_params['TZID'] || ical_params[:TZID]
431
841
  return nil if ugly_tzid.nil?
842
+
432
843
  tzid = Array(ugly_tzid).first.to_s.gsub(/^(["'])|(["'])$/, '')
844
+ return nil if tzid.empty?
845
+
433
846
  ActiveSupport::TimeZone[tzid]
847
+ rescue StandardError
848
+ # Uncomment for debugging icalendar gem compatibility issues:
849
+ # warn "[icalendar-rrule] Failed to extract timezone: #{e.message}"
850
+ nil
434
851
  end
852
+
853
+ ##
854
+ # Attempts to determine the system's timezone.
855
+ # Tries multiple methods in order of reliability.
856
+ #
857
+ # @note see also https://rubygems.org/gems/timezone_local - it does about the same as this.
858
+ #
859
+ # @return [ActiveSupport::TimeZone, nil] the system timezone or nil if it cannot be determined.
860
+ # @api private
861
+ def _guess_system_timezone
862
+ # Method 1: Rails/ActiveSupport Time.zone (most reliable if set)
863
+ return Time.zone if Time.zone.is_a?(ActiveSupport::TimeZone)
864
+
865
+ # Method 2: ENV['TZ'] environment variable
866
+ if ENV['TZ']
867
+ tz = ActiveSupport::TimeZone[ENV['TZ']]
868
+ return tz if tz
869
+ end
870
+
871
+ # Method 3: Try TZInfo if available (optional dependency)
872
+ begin
873
+ require 'tzinfo'
874
+ tz_identifier = TZInfo::Timezone.default_timezone.identifier
875
+ tz = ActiveSupport::TimeZone[tz_identifier]
876
+ return tz if tz
877
+ rescue LoadError, StandardError
878
+ # TZInfo not available or failed, continue
879
+ end
880
+
881
+ # Method 4: Read /etc/timezone on Linux (Debian/Ubuntu style)
882
+ if File.readable?('/etc/timezone')
883
+ tz_name = File.read('/etc/timezone').strip
884
+ tz = ActiveSupport::TimeZone[tz_name]
885
+ return tz if tz
886
+ end
887
+
888
+ # Method 5: Parse /etc/localtime symlink (common on many Unix systems)
889
+ if File.symlink?('/etc/localtime')
890
+ link = File.readlink('/etc/localtime')
891
+ # Extract timezone name from path like /usr/share/zoneinfo/Europe/Berlin
892
+ if link =~ %r{zoneinfo/(.+)$}
893
+ tz = ActiveSupport::TimeZone[$1]
894
+ return tz if tz
895
+ end
896
+ end
897
+
898
+ nil
899
+ rescue StandardError
900
+ nil
901
+ end
902
+
435
903
  end
436
904
  end
437
905
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: icalendar-rrule
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harald Postner
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2020-07-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '5.1'
18
+ version: '8.0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '5.1'
25
+ version: '8.0'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: icalendar
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -44,14 +43,14 @@ dependencies:
44
43
  requirements:
45
44
  - - ">="
46
45
  - !ruby/object:Gem::Version
47
- version: '0.16'
46
+ version: '0.17'
48
47
  type: :runtime
49
48
  prerelease: false
50
49
  version_requirements: !ruby/object:Gem::Requirement
51
50
  requirements:
52
51
  - - ">="
53
52
  - !ruby/object:Gem::Version
54
- version: '0.16'
53
+ version: '0.17'
55
54
  - !ruby/object:Gem::Dependency
56
55
  name: bundler
57
56
  requirement: !ruby/object:Gem::Requirement
@@ -144,11 +143,13 @@ executables: []
144
143
  extensions: []
145
144
  extra_rdoc_files: []
146
145
  files:
146
+ - ".github/workflows/ruby.yml"
147
147
  - ".gitignore"
148
148
  - ".rspec"
149
149
  - ".rubocop.yml"
150
150
  - ".travis.yml"
151
151
  - ".yardopts"
152
+ - CHANGELOG.md
152
153
  - Gemfile
153
154
  - LICENSE.txt
154
155
  - README.md
@@ -157,6 +158,7 @@ files:
157
158
  - lib/icalendar-rrule.rb
158
159
  - lib/icalendar/rrule.rb
159
160
  - lib/icalendar/rrule/occurrence.rb
161
+ - lib/icalendar/rrule/time_refinements.rb
160
162
  - lib/icalendar/rrule/version.rb
161
163
  - lib/icalendar/scannable-calendar.rb
162
164
  - lib/icalendar/schedulable-component.rb
@@ -167,7 +169,6 @@ metadata:
167
169
  homepage_uri: https://github.com/free-creations/icalendar-rrule
168
170
  source_code_uri: https://github.com/free-creations/icalendar-rrule
169
171
  bug_tracker_uri: https://github.com/free-creations/icalendar-rrule/issues
170
- post_install_message:
171
172
  rdoc_options: []
172
173
  require_paths:
173
174
  - lib
@@ -175,15 +176,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
175
176
  requirements:
176
177
  - - ">="
177
178
  - !ruby/object:Gem::Version
178
- version: '2.5'
179
+ version: '3.2'
179
180
  required_rubygems_version: !ruby/object:Gem::Requirement
180
181
  requirements:
181
182
  - - ">="
182
183
  - !ruby/object:Gem::Version
183
184
  version: '0'
184
185
  requirements: []
185
- rubygems_version: 3.1.2
186
- signing_key:
186
+ rubygems_version: 4.0.1
187
187
  specification_version: 4
188
188
  summary: Helper for ICalendars with recurring events.
189
189
  test_files: []