periodoxical 2.2.0 → 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: ace9209a6c531a382b264e87513dc6e5075499fc90f1eba313404368d96e58ba
4
- data.tar.gz: e1771a96128f204df520f39bfcb54d3a294c130da07d0c9f971b98908fe985ce
3
+ metadata.gz: 169a25ff421b59755693ae84a7b909dde7346c3c3d05a7ebef0e363227ba247d
4
+ data.tar.gz: a4ff2b417b4be13c3c98ee4c9843aab54d7f346447a486084dcba32e20ea388a
5
5
  SHA512:
6
- metadata.gz: aaaf84f0c0af53ad1df2f595298a8327fc67574b7a35c08a27a921f0719ef6eea0990a7ee28423755f3baa0abe78d31e4bad7bcd42f82d1c9ebe6607b0fe0574
7
- data.tar.gz: 8eed0bfd9236bb43084c0427a50ac6ce6b793411d30adffd1d12b303ae57d0cf8d5dc0281b1c654aa15584a436066dea7f1a39a5283bdc7ef398df9dde56ec5c
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.0)
4
+ periodoxical (2.3.0)
5
5
  tzinfo (~> 2.0, >= 2.0.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -240,7 +240,8 @@ Periodoxical.generate(
240
240
  ]
241
241
  ```
242
242
 
243
- ### Example 6 - Specify nth day-of-week in month (ie. first Monday of the Month, second Tuesday of the Month, last Friday of Month)
243
+ ### Specify nth day-of-week in month (ie. first Monday of the Month, second Tuesday of the Month, last Friday of Month)
244
+
244
245
  As a Ruby dev, I want to generate timeblocks for **8AM - 9AM** on the **first and second Mondays** and **last Fridays** of every month starting in June 2024. I can do this with the `nth_day_of_week_in_month` param.
245
246
 
246
247
  ```rb
@@ -281,7 +282,7 @@ Periodoxical.generate(
281
282
  ]
282
283
  ```
283
284
 
284
- ### Example 7 - Exclude time blocks using the `exclusion_dates` and `exclusion_times` parameters
285
+ ### Exclude time blocks using the `exclusion_dates` and `exclusion_times` parameters
285
286
  As a Ruby dev, I want to generate timeblocks for **8AM - 9AM** on **Mondays**, except for the **Monday of June 10, 2024**. I can do this using the `exlcusion_dates` parameter.
286
287
 
287
288
  ```rb
@@ -367,7 +368,7 @@ Periodoxical.generate(
367
368
  ]
368
369
  ```
369
370
 
370
- ### Example 8 - Every-other-nth day-of-week rules (ie. every other Tuesday, every 3rd Wednesday, every 10th Friday)
371
+ ### Every-other-nth day-of-week rules (ie. every other Tuesday, every 3rd Wednesday, every 10th Friday)
371
372
 
372
373
  As a Ruby dev, I want to generate timeblocks for **9AM- 10AM** on **every Monday**, but **every other Tuesday**, and **every other 3rd Wednesday**. I can do this using the `days_of_week` parameter with the `every` and `every_other_nth` keys to specify the every-other-nth-rules.
373
374
 
@@ -561,6 +562,39 @@ Periodoxical.generate(
561
562
  ]
562
563
  ```
563
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
+
564
598
  ## Development
565
599
 
566
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.
@@ -27,11 +27,14 @@ module Periodoxical
27
27
  tb_2_start = time_block_2[:start]
28
28
  tb_2_end = time_block_2[:end]
29
29
 
30
- # Basicall overlap is when one starts before the other has ended
30
+ # Basically overlap is when one starts before the other has ended
31
31
  return true if tb_1_end > tb_2_start && tb_1_end < tb_2_end
32
32
  # By symmetry
33
33
  return true if tb_2_end > tb_1_start && tb_2_end < tb_1_end
34
34
 
35
+ # Handle the edge case where they start/end at the same time
36
+ return true if tb_1_start == tb_2_start || tb_1_end == tb_2_end
37
+
35
38
  false
36
39
  end
37
40
 
@@ -1,3 +1,3 @@
1
1
  module Periodoxical
2
- VERSION = "2.2.0"
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,11 +158,18 @@ 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
- time = Time.strptime(time_str, "%I:%M%p")
172
+ time = Time.strptime(time_str, '%I:%M%p')
152
173
  date_time = DateTime.new(
153
174
  date.year,
154
175
  date.month,
@@ -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
@@ -209,19 +274,21 @@ module Periodoxical
209
274
  # }
210
275
  # Generates time block but also checks if we should stop generating
211
276
  def append_to_output_and_check_limit(time_block)
212
- # Check if this particular time is conflicts with any times from `exclusion_times`.
213
- return if overlaps_with_an_excluded_time?(time_block)
214
- return if before_starting_from_or_after_ending_at?(time_block)
215
-
216
277
  strtm = time_str_to_object(@current_date, time_block[:start_time])
217
278
  endtm = time_str_to_object(@current_date, time_block[:end_time])
218
279
 
280
+ return if strtm.nil? || endtm.nil?
281
+
219
282
  if @duration
220
283
  split_by_duration_and_append(strtm, endtm)
221
284
  else
285
+ # Check if this particular time is conflicts with any times from `exclusion_times`.
286
+ return if before_starting_from_or_after_ending_at?(time_block)
287
+ return if overlaps_with_an_excluded_time?({ start: strtm, end: endtm })
288
+
222
289
  @output << {
223
290
  start: strtm,
224
- end: endtm
291
+ end: endtm
225
292
  }
226
293
  increment_and_check_limit
227
294
  end
@@ -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
@@ -410,19 +479,22 @@ module Periodoxical
410
479
  false
411
480
  end
412
481
 
482
+ # @param [Hash] time_block
483
+ # Ex:
484
+ # {
485
+ # start: #<DateTime>,
486
+ # end: #<DateTime>,
487
+ # }
413
488
  # @return [Boolean]
414
489
  # Whether or not the given `time_block` in the @current_date and
415
490
  # @time_zone overlaps with the times in `exclusion_times`.
416
- def overlaps_with_an_excluded_time?(time_block)
491
+ def overlaps_with_an_excluded_time?(tm_blck)
417
492
  return false unless @exclusion_times
418
493
 
419
494
  @exclusion_times.each do |exclusion_timeblock|
420
495
  return true if overlap?(
421
496
  exclusion_timeblock,
422
- {
423
- start: time_str_to_object(@current_date, time_block[:start_time]),
424
- end: time_str_to_object(@current_date, time_block[:end_time]),
425
- }
497
+ tm_blck
426
498
  )
427
499
  end
428
500
 
@@ -434,13 +506,15 @@ module Periodoxical
434
506
  si = strtm
435
507
  ei = strtm + delta
436
508
  while ei <= endtm
437
- @output << {
438
- start: si,
439
- end: ei
440
- }
509
+ unless overlaps_with_an_excluded_time?({ start: si, end: ei })
510
+ @output << {
511
+ start: si,
512
+ end: ei
513
+ }
514
+ increment_and_check_limit
515
+ end
441
516
  si += delta
442
517
  ei += delta
443
- increment_and_check_limit
444
518
  end
445
519
  end
446
520
  end
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.0
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-06-17 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