icalendar-rrule 0.2.0 → 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: b891ac836e382f65aa522d6fc6b049483b3296deef1fe9d2bb196ed6bb2de9bc
4
- data.tar.gz: 5f99a1568a014411c01559505dd71068e630fca5cdbeb3e32fbb246fecd24190
3
+ metadata.gz: 657bfb18be866339dcd5fa55c355864c8d5870ca96d71f83496d0e8065495c43
4
+ data.tar.gz: 37dd92456663dfb2b257db55146ca6868f492549a632a7d39f547896d3f44136
5
5
  SHA512:
6
- metadata.gz: ab80482e0f12d0cc47b11e28bbcf7f887bf42e50ed9e6d74dea0f24202b42215c62585347e94e72e8cb5dd38e04bf0e507745520092c7a11af36bc48182ffea1
7
- data.tar.gz: 6a62fab4d640445562924807ebd7ed8eeadba97d1a5630f2c8bcf399977e6d1ece789efe17dbf21c50e05ee04b289bf81220e81075a3a698ef79531dcba5dbe2
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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
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
+
5
13
  ## [0.2.0] - 2025-12-24
6
14
 
7
15
  ### Added
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.2.0'
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,26 +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)
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)
55
69
 
56
- # Get the target timezone from the component
57
- target_tz = comp.start_time.time_zone
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)
58
77
 
59
- occurrences.each do |oc|
60
- # Interpret the time components AS IF they're already in target timezone
61
- # (don't convert, just reconstruct in the right zone)
62
- start_tz = target_tz.local(
63
- oc.start_time.year, oc.start_time.month, oc.start_time.day,
64
- oc.start_time.hour, oc.start_time.min, oc.start_time.sec
65
- )
66
- end_tz = target_tz.local(
67
- oc.end_time.year, oc.end_time.month, oc.end_time.day,
68
- oc.end_time.hour, oc.end_time.min, oc.end_time.sec
69
- )
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)
70
82
 
71
- new_oc = Icalendar::Rrule::Occurrence.new(self, comp, start_tz, end_tz)
83
+ new_oc = Icalendar::Rrule::Occurrence.new(self, base_component, start_tz, end_tz)
72
84
  result << new_oc
73
85
  end
74
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
  ##
@@ -214,6 +218,7 @@ module Icalendar
214
218
  # @return [Boolean] true if the component is an Event scheduled for an entire day,
215
219
  # false for tasks or timed events
216
220
  def all_day?
221
+ # todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
217
222
  return false unless self.is_a?(Icalendar::Event)
218
223
 
219
224
  _dtstart.is_a?(Icalendar::Values::Date) ||
@@ -234,13 +239,14 @@ module Icalendar
234
239
  #
235
240
  # @return [Boolean] true if the component has no duration
236
241
  def single_timestamp?
237
- return false if start_time.nil? || end_time.nil?
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 ????
238
244
  # Compare at second precision (ignore potential microsecond differences)
239
245
  start_time.to_i == end_time.to_i
240
246
  end
241
247
 
242
248
  ##
243
- # Make sure, that we can always query for a _rrule_ array.
249
+ # Make sure that we can always query for a _rrule_ array.
244
250
  # @return [array] an array of _ical repeat-rules_ (or an empty array
245
251
  # if no repeat-rules are defined for this component).
246
252
  # @api private
@@ -251,9 +257,10 @@ module Icalendar
251
257
  end
252
258
 
253
259
  ##
254
- # Make sure, that we can always query for an _exdate_ array.
255
- # @return [array<ActiveSupport::TimeWithZone>] an array of _ical exdates_ (or an empty array
256
- # 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.
257
264
  # @api private
258
265
  def _exdates
259
266
  Array(exdate).flatten
@@ -311,7 +318,7 @@ module Icalendar
311
318
  ##
312
319
  # Like the for _exdates, also for these dates do not schedule recurrence items.
313
320
  #
314
- # @return [array<ActiveSupport::TimeWithZone>] an array of dates.
321
+ # @return [Array<Object>] an array of dates.
315
322
  # @api private
316
323
  def _overwritten_dates
317
324
  result = []
@@ -325,7 +332,7 @@ module Icalendar
325
332
  end
326
333
 
327
334
  ##
328
- # 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.
329
336
  # @return [array] an array of _ical rdates_ (or an empty array
330
337
  # if no repeat-rules are defined for this component).
331
338
  # @api private
@@ -339,33 +346,110 @@ module Icalendar
339
346
  # Creates a schedule for this event
340
347
  # @return [IceCube::Schedule]
341
348
  def schedule # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
342
- # Calculate duration in seconds
343
- duration_seconds = (end_time.to_i - start_time.to_i) # Integer seconds
349
+ # Calculate the duration of this base event in seconds
350
+ duration_seconds = (end_time.to_i - start_time.to_i) # Integer seconds
344
351
 
345
352
  # Create a schedule with start_time and duration
346
- # Convert to Ruby Time for IceCube compatibility
347
- schedule = IceCube::Schedule.new(start_time.to_time, duration: duration_seconds)
353
+ # Convert to floating Time for IceCube compatibility
354
+ schedule = IceCube::Schedule.new(_to_floating_time(start_time, _timezone_for_start), duration: duration_seconds)
348
355
 
349
356
  _rrules.each do |rrule|
350
- 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)
351
360
  schedule.add_recurrence_rule(ice_cube_recurrence_rule)
352
361
  end
353
362
 
354
- _exdates.each do |time|
355
- 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))
356
365
  end
357
366
 
358
- _overwritten_dates.each do |time|
359
- 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))
360
369
  end
361
370
 
362
371
  rdates = _rdates
363
- rdates.each do |time|
364
- 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))
365
374
  end
366
375
  schedule
367
376
  end
368
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
+
369
453
  ##
370
454
  # Transform the given object into an object of type `ActiveSupport::TimeWithZone`.
371
455
  #
@@ -421,8 +505,8 @@ module Icalendar
421
505
  return _date_to_time_with_zone(date_time_value, timezone)
422
506
 
423
507
  elsif date_time_value.is_a?(Time)
424
- # Preserve Time's timezone by converting to UTC first, then to target timezone
425
- return timezone.at(date_time_value.getutc)
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)
426
510
 
427
511
  elsif date_time_value.respond_to?(:to_time)
428
512
  return timezone.at(date_time_value.to_time)
@@ -435,6 +519,7 @@ module Icalendar
435
519
  # Oops, the given object is unusable, we'll give back the NULL_DATE
436
520
  timezone.at(NULL_TIME)
437
521
  end
522
+
438
523
  # rubocop:enable Metrics/MethodLength,Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
439
524
 
440
525
  ##
@@ -447,9 +532,158 @@ module Icalendar
447
532
  timezone.local(d.year, d.month, d.day)
448
533
  end
449
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
+
450
683
  ##
451
684
  # Heuristic to determine the best timezone that shall be used in this component.
452
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.
453
687
  def component_timezone
454
688
  # let's try sequentially, the first non-nil wins.
455
689
  timezone ||= _extract_explicit_timezone(_dtend)
@@ -462,10 +696,26 @@ module Icalendar
462
696
  timezone || ActiveSupport::TimeZone['UTC']
463
697
  end
464
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
+
465
715
  ##
466
716
  # Try to determine this components time zone by inspecting the parents calendar.
467
717
  # @return[ActiveSupport::TimeZone, nil] the first valid timezone found in the
468
- # parent calender or nil if none could be found.
718
+ # parent calendar or nil if none could be found.
469
719
  #
470
720
  # rubocop:disable Metrics/CyclomaticComplexity
471
721
  def _extract_calendar_timezone
@@ -483,6 +733,7 @@ module Icalendar
483
733
  rescue StandardError
484
734
  nil
485
735
  end
736
+
486
737
  # rubocop:enable Metrics/CyclomaticComplexity
487
738
 
488
739
  ##
@@ -501,9 +752,9 @@ module Icalendar
501
752
  # @return [ActiveSupport::TimeZone, nil] the explicitly set timezone, or nil if none found.
502
753
  # @api private
503
754
  def _extract_explicit_timezone(date_time)
504
- timezone ||= _extract_ical_time_zone(date_time) # try with ical TZID parameter (most specific)
505
- timezone ||= _extract_act_sup_timezone(date_time) # is the given value already ActiveSupport::TimeWithZone?
506
- timezone || _extract_value_time_zone(date_time) # is the ical.value of type ActiveSupport::TimeWithZone?
755
+ timezone ||= _extract_ical_time_zone(date_time) # try with ical TZID parameter (most specific)
756
+ timezone ||= _extract_act_sup_timezone(date_time) # is the given value already ActiveSupport::TimeWithZone?
757
+ timezone || _extract_value_time_zone(date_time) # is the ical.value of type ActiveSupport::TimeWithZone?
507
758
  end
508
759
 
509
760
  ##
@@ -545,6 +796,7 @@ module Icalendar
545
796
  # Should respond to :utc_offset (e.g., Time, DateTime, Icalendar::Values::DateTime).
546
797
  # @return [ActiveSupport::TimeZone, nil] an ActiveSupport::TimeZone matching the UTC offset,
547
798
  # or nil if no match is found or an error occurs.
799
+ # @deprecated makes little sense and can be avoided.
548
800
  # @api private
549
801
  def _guess_timezone_from_offset(date_time)
550
802
  # Extract value from Icalendar::Values::DateTime if needed
@@ -602,6 +854,8 @@ module Icalendar
602
854
  # Attempts to determine the system's timezone.
603
855
  # Tries multiple methods in order of reliability.
604
856
  #
857
+ # @note see also https://rubygems.org/gems/timezone_local - it does about the same as this.
858
+ #
605
859
  # @return [ActiveSupport::TimeZone, nil] the system timezone or nil if it cannot be determined.
606
860
  # @api private
607
861
  def _guess_system_timezone
@@ -646,7 +900,6 @@ module Icalendar
646
900
  nil
647
901
  end
648
902
 
649
-
650
903
  end
651
904
  end
652
905
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: icalendar-rrule
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harald Postner
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '5.1'
18
+ version: '8.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '5.1'
25
+ version: '8.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: icalendar
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '0.16'
46
+ version: '0.17'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '0.16'
53
+ version: '0.17'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: bundler
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -143,6 +143,7 @@ executables: []
143
143
  extensions: []
144
144
  extra_rdoc_files: []
145
145
  files:
146
+ - ".github/workflows/ruby.yml"
146
147
  - ".gitignore"
147
148
  - ".rspec"
148
149
  - ".rubocop.yml"
@@ -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
@@ -174,7 +176,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
174
176
  requirements:
175
177
  - - ">="
176
178
  - !ruby/object:Gem::Version
177
- version: '2.5'
179
+ version: '3.2'
178
180
  required_rubygems_version: !ruby/object:Gem::Requirement
179
181
  requirements:
180
182
  - - ">="