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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +37 -3
- data/lib/periodoxical/helpers.rb +4 -1
- data/lib/periodoxical/version.rb +1 -1
- data/lib/periodoxical.rb +93 -19
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 169a25ff421b59755693ae84a7b909dde7346c3c3d05a7ebef0e363227ba247d
|
4
|
+
data.tar.gz: a4ff2b417b4be13c3c98ee4c9843aab54d7f346447a486084dcba32e20ea388a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cd1a0ed4ba0f34ccf10fede5d036bddf317e801abdd8734931c3f2cdad707d7057ef6eee25410bf8e3af8c46c1299e982fa029bbc10a48acbc6e5360ab8ead68
|
7
|
+
data.tar.gz: 0d6c4234da26fe9c67653b32c69978e9703390bd10d6571703c6f56b5db2f5b2684f3ebd9543a37b94716d18c78e24584616f89ffd2b019c244a99bd0a917326
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -240,7 +240,8 @@ Periodoxical.generate(
|
|
240
240
|
]
|
241
241
|
```
|
242
242
|
|
243
|
-
###
|
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
|
-
###
|
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
|
-
###
|
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.
|
data/lib/periodoxical/helpers.rb
CHANGED
@@ -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
|
-
#
|
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
|
|
data/lib/periodoxical/version.rb
CHANGED
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
|
-
#
|
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,
|
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
|
-
|
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?(
|
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
|
-
|
438
|
-
|
439
|
-
|
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.
|
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:
|
11
|
+
date: 2025-08-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: tzinfo
|