periodoxical 2.2.1 → 2.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: 9a17e3bd272fdd537b16689e871aa834c6388202d36d5ed5bf420150c0ff62bd
4
- data.tar.gz: 8c08b584338f8d2e14cb8c0c8ce1ec4171d5fa4c946328911b25302aa621e92e
3
+ metadata.gz: 169a25ff421b59755693ae84a7b909dde7346c3c3d05a7ebef0e363227ba247d
4
+ data.tar.gz: a4ff2b417b4be13c3c98ee4c9843aab54d7f346447a486084dcba32e20ea388a
5
5
  SHA512:
6
- metadata.gz: 76df9351805289b310b079dd0a44587630db263101a18777ffa4612dacd5e846313fc56b8836ffa42fe7126926456d11273c218187dda0542d5b76c65a6a0451
7
- data.tar.gz: afed95be8919055c47c5b118b6e01f944b03e16fc2c87046157ece131d35d0078c1dc5cbad804140afb9cdc975283c9d464536a4b65d7549181b5e0781afb628
6
+ metadata.gz: cd1a0ed4ba0f34ccf10fede5d036bddf317e801abdd8734931c3f2cdad707d7057ef6eee25410bf8e3af8c46c1299e982fa029bbc10a48acbc6e5360ab8ead68
7
+ data.tar.gz: 0d6c4234da26fe9c67653b32c69978e9703390bd10d6571703c6f56b5db2f5b2684f3ebd9543a37b94716d18c78e24584616f89ffd2b019c244a99bd0a917326
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- periodoxical (2.2.1)
4
+ periodoxical (2.3.0)
5
5
  tzinfo (~> 2.0, >= 2.0.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -562,6 +562,39 @@ Periodoxical.generate(
562
562
  ]
563
563
  ```
564
564
 
565
+ ### Daylight Saving Time (DST) handling
566
+
567
+ Periodoxical supports explicit strategies for DST edge cases when converting local times to UTC:
568
+
569
+ - ambiguous_time: how to resolve fall-back ambiguous local times (e.g., 01:30 occurs twice)
570
+ - :first → choose the first occurrence (usually daylight time)
571
+ - :last → choose the second occurrence (standard time)
572
+ - :raise → raise TZInfo::AmbiguousTime (default)
573
+ - gap_strategy: how to handle spring-forward missing local times (e.g., 02:30 does not exist)
574
+ - :advance → move forward by gap_shift_minutes to the next valid local time
575
+ - :skip → drop the invalid block
576
+ - :raise → raise TZInfo::PeriodNotFound (default)
577
+ - gap_shift_minutes: integer minutes to advance when gap_strategy is :advance (default: 60)
578
+
579
+ Example: be resilient around DST changes
580
+
581
+ ```rb
582
+ Periodoxical.generate(
583
+ time_zone: 'America/Los_Angeles',
584
+ ambiguous_time: :last, # pick standard time on fall back
585
+ gap_strategy: :advance, # move to next valid time on spring forward
586
+ gap_shift_minutes: 60,
587
+ starting_from: '2025-03-01',
588
+ ending_at: '2025-11-30',
589
+ time_blocks: [ { start_time: '1:30AM', end_time: '2:30AM' } ]
590
+ )
591
+ ```
592
+
593
+ Notes:
594
+ - Period offsets are applied based on the period at that instant, not the current period.
595
+ - If you choose `gap_strategy: :skip`, blocks that convert to invalid local times will be omitted.
596
+
597
+
565
598
  ## Development
566
599
 
567
600
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -1,3 +1,3 @@
1
1
  module Periodoxical
2
- VERSION = "2.2.1"
2
+ VERSION = "2.3.0"
3
3
  end
data/lib/periodoxical.rb CHANGED
@@ -71,6 +71,14 @@ module Periodoxical
71
71
  # - 9:00AM - 9:20AM
72
72
  # - 9:20AM - 9:40AM
73
73
  # - 9:40AM - 10:00AM
74
+ # @param [Symbol] ambiguous_time
75
+ # How to resolve DST fall-back ambiguous local times (e.g. 01:30 occurs twice).
76
+ # Allowed: :first (daylight time), :last (standard time), :raise (default).
77
+ # @param [Symbol] gap_strategy
78
+ # How to handle DST spring-forward missing local times (e.g. 02:30 does not exist).
79
+ # Allowed: :advance (shift forward), :skip (omit block), :raise (default).
80
+ # @param [Integer] gap_shift_minutes
81
+ # Minutes to advance when gap_strategy is :advance. Default: 60.
74
82
  def initialize(
75
83
  starting_from:,
76
84
  ending_at: nil,
@@ -84,10 +92,16 @@ module Periodoxical
84
92
  nth_day_of_week_in_month: nil,
85
93
  days_of_month: nil,
86
94
  duration: nil,
87
- months: nil
95
+ months: nil,
96
+ ambiguous_time: :raise,
97
+ gap_strategy: :raise,
98
+ gap_shift_minutes: 60
88
99
  )
89
100
 
90
101
  @time_zone = TZInfo::Timezone.get(time_zone)
102
+ @ambiguous_time = ambiguous_time
103
+ @gap_strategy = gap_strategy
104
+ @gap_shift_minutes = gap_shift_minutes
91
105
  if days_of_week.is_a?(Array)
92
106
  @days_of_week = deep_symbolize_keys(days_of_week)
93
107
  elsif days_of_week.is_a?(Hash)
@@ -144,9 +158,16 @@ module Periodoxical
144
158
 
145
159
  private
146
160
 
161
+ # @param [Date] date
147
162
  # @param [String] time_str
148
163
  # Ex: '9:00AM'
149
- # @param [Date] date
164
+ #
165
+ # @return [DateTime]
166
+ # Converts a local date and time string to UTC and returns a DateTime with the
167
+ # timezone offset corresponding to the zone period at that instant.
168
+ # Handles DST transitions explicitly:
169
+ # - Ambiguous (fall-back) times resolved via `@ambiguous_time`.
170
+ # - Missing (spring-forward) times handled via `@gap_strategy` and `@gap_shift_minutes`.
150
171
  def time_str_to_object(date, time_str)
151
172
  time = Time.strptime(time_str, '%I:%M%p')
152
173
  date_time = DateTime.new(
@@ -157,7 +178,51 @@ module Periodoxical
157
178
  time.min,
158
179
  time.sec,
159
180
  )
160
- @time_zone.local_to_utc(date_time).new_offset(@time_zone.current_period.offset.utc_total_offset)
181
+ utc_time = begin
182
+ @time_zone.local_to_utc(date_time) do |periods|
183
+ case @ambiguous_time
184
+ when :first
185
+ periods.first
186
+ when :last
187
+ periods.last
188
+ when :raise
189
+ raise TZInfo::AmbiguousTime, date_time.to_s
190
+ else
191
+ periods.last
192
+ end
193
+ end
194
+ rescue TZInfo::AmbiguousTime
195
+ case @ambiguous_time
196
+ when :first
197
+ @time_zone.local_to_utc(date_time) { |periods| periods.first }
198
+ when :last
199
+ @time_zone.local_to_utc(date_time) { |periods| periods.last }
200
+ else
201
+ raise
202
+ end
203
+ rescue TZInfo::PeriodNotFound
204
+ case @gap_strategy
205
+ when :advance
206
+ step = Rational(@gap_shift_minutes, 24 * 60)
207
+ shifted = date_time
208
+ attempts = 0
209
+ begin
210
+ shifted += step
211
+ attempts += 1
212
+ raise TZInfo::PeriodNotFound if attempts > 240
213
+ @time_zone.local_to_utc(shifted)
214
+ rescue TZInfo::PeriodNotFound
215
+ retry
216
+ end
217
+ when :skip
218
+ return nil
219
+ else
220
+ raise
221
+ end
222
+ end
223
+
224
+ period_at_utc = @time_zone.period_for_utc(utc_time)
225
+ utc_time.new_offset(period_at_utc.offset.utc_total_offset)
161
226
  end
162
227
 
163
228
  # @param [Date] date
@@ -212,6 +277,8 @@ module Periodoxical
212
277
  strtm = time_str_to_object(@current_date, time_block[:start_time])
213
278
  endtm = time_str_to_object(@current_date, time_block[:end_time])
214
279
 
280
+ return if strtm.nil? || endtm.nil?
281
+
215
282
  if @duration
216
283
  split_by_duration_and_append(strtm, endtm)
217
284
  else
@@ -395,6 +462,7 @@ module Periodoxical
395
462
 
396
463
  if @starting_from.is_a?(DateTime)
397
464
  start_time = time_str_to_object(@current_date, time_block[:start_time])
465
+ return false if start_time.nil?
398
466
 
399
467
  # If the candidate time block is starting earlier than @starting_from, we want to skip it
400
468
  return true if start_time < @starting_from
@@ -402,6 +470,7 @@ module Periodoxical
402
470
 
403
471
  if @ending_at.is_a?(DateTime)
404
472
  end_time = time_str_to_object(@current_date, time_block[:end_time])
473
+ return false if end_time.nil?
405
474
 
406
475
  # If the candidate time block is ending after @ending_at, we want to skip it
407
476
  return true if end_time > @ending_at
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: periodoxical
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.1
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steven Li
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-07-06 00:00:00.000000000 Z
11
+ date: 2025-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: tzinfo