├── .gitignore
├── .rspec
├── CODE_OF_CONDUCT.md
├── Gemfile
├── Gemfile.lock
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
├── console
└── setup
├── lib
├── periodoxical.rb
└── periodoxical
│ ├── helpers.rb
│ ├── validation.rb
│ └── version.rb
├── periodoxical.gemspec
└── spec
├── periodoxical_spec.rb
└── spec_helper.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /spec/reports/
8 | /tmp/
9 |
10 | # rspec failure tracking
11 | .rspec_status
12 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --format documentation
2 | --color
3 | --require spec_helper
4 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of experience,
9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at stevenJLi@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [http://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: http://contributor-covenant.org
74 | [version]: http://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4 |
5 | # Specify your gem's dependencies in periodoxical.gemspec
6 | gemspec
7 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | periodoxical (2.2.1)
5 | tzinfo (~> 2.0, >= 2.0.0)
6 |
7 | GEM
8 | remote: https://rubygems.org/
9 | specs:
10 | binding_of_caller (1.0.1)
11 | debug_inspector (>= 1.2.0)
12 | byebug (11.1.3)
13 | coderay (1.1.3)
14 | concurrent-ruby (1.2.3)
15 | debug_inspector (1.2.0)
16 | diff-lcs (1.5.1)
17 | method_source (1.1.0)
18 | pry (0.14.2)
19 | coderay (~> 1.1)
20 | method_source (~> 1.0)
21 | pry-byebug (3.10.1)
22 | byebug (~> 11.0)
23 | pry (>= 0.13, < 0.15)
24 | pry-stack_explorer (0.6.1)
25 | binding_of_caller (~> 1.0)
26 | pry (~> 0.13)
27 | rake (12.3.3)
28 | rspec (3.13.0)
29 | rspec-core (~> 3.13.0)
30 | rspec-expectations (~> 3.13.0)
31 | rspec-mocks (~> 3.13.0)
32 | rspec-core (3.13.0)
33 | rspec-support (~> 3.13.0)
34 | rspec-expectations (3.13.0)
35 | diff-lcs (>= 1.2.0, < 2.0)
36 | rspec-support (~> 3.13.0)
37 | rspec-mocks (3.13.1)
38 | diff-lcs (>= 1.2.0, < 2.0)
39 | rspec-support (~> 3.13.0)
40 | rspec-support (3.13.1)
41 | tzinfo (2.0.6)
42 | concurrent-ruby (~> 1.0)
43 |
44 | PLATFORMS
45 | arm64-darwin-22
46 |
47 | DEPENDENCIES
48 | bundler (~> 2.4)
49 | periodoxical!
50 | pry-byebug (~> 3.10)
51 | pry-stack_explorer (~> 0.6)
52 | rake (~> 12.3.3)
53 | rspec (~> 3.0)
54 |
55 | BUNDLED WITH
56 | 2.4.22
57 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2024 Steven Li
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Periodoxical
2 |
3 | _"Up, and down, and in the end, it's only round and round and round..._" - Pink Floyd, "Us and Them"
4 |
5 |
6 |

7 |
(Image Courtesy of "Pink Floyd: Time," directed by Ian Eames , ©1973)
8 |
9 |
10 |
11 |
12 | Generate periodic datetime blocks based on provided rules/conditions. Great for (but not limited to) calendar and scheduling applications. See Usage for detailed examples.
13 |
14 | ## Installation
15 |
16 | Add this line to your application's Gemfile:
17 |
18 | ```ruby
19 | gem 'periodoxical'
20 | ```
21 |
22 | And then execute:
23 |
24 | $ bundle
25 |
26 | Or install it yourself as:
27 |
28 | $ gem install periodoxical
29 |
30 | ## Usage
31 |
32 | ### Basic Example
33 | As a Ruby dev, I want to generate all the datetime blocks of **9:00AM - 10:30AM** for all days from **May 23, 2024** to **May 26, 2024** inclusive.
34 |
35 | ```rb
36 | Periodoxical.generate(
37 | time_zone: 'America/Los_Angeles',
38 | time_blocks: [
39 | {
40 | start_time: '9:00AM',
41 | end_time: '10:30AM'
42 | },
43 | ],
44 | starting_from: '2024-05-23',
45 | ending_at: '2024-05-26',
46 | )
47 | #=>
48 | [
49 | {
50 | start: #,
51 | end: #,
52 | },
53 | {
54 | start: #,
55 | end: #,
56 | },
57 | {
58 | start: #,
59 | end: #,
60 | },
61 | {
62 | start: #,
63 | end: #,
64 | }
65 | ]
66 | ```
67 |
68 | The `starting_from` and `ending_at` params can also accept datetimes in ISO 8601 format. This example generate all the datetime blocks of **9:00AM - 10:30AM** but starting from **May 23, 2024 at 9:30AM**.
69 |
70 | ```rb
71 | Periodoxical.generate(
72 | time_zone: 'America/Los_Angeles',
73 | time_blocks: [
74 | {
75 | start_time: '9:00AM',
76 | end_time: '10:30AM'
77 | },
78 | ],
79 | starting_from: '2024-05-23T09:30:00-07:00', # can be string in iso8601 format
80 | ending_at: DateTime.parse('2024-05-26T17:00:00-07:00'), # or an instance of DateTime
81 | )
82 | #=> [
83 | # 2024-05-23 was skipped because the 9AM time block was before
84 | # the `starting_from` of '2024-05-23T09:30:00-07:00'
85 | {
86 | start_time: #,
87 | end_time: #,
88 | },
89 | ...
90 | ]
91 | ```
92 |
93 | ### Specify days of the week
94 | As a Ruby dev, I want to generate all the datetime blocks of **9:00AM - 10:30AM** and **2:00PM - 2:30PM**, on **Mondays**, **Wednesdays**, and **Thursdays**, between the dates of **May 23, 2024** and **June 12, 2024**, inclusive. I can do this using the `days_of_week` parameter. This can be represented visually as:
95 |
96 |
97 |

98 |
(image courtesy of Cal.com)
99 |
100 |
101 |
102 |
103 | ```rb
104 | Periodoxical.generate(
105 | time_zone: 'America/Los_Angeles',
106 | days_of_week: %w[mon wed thu],
107 | time_blocks: [
108 | {
109 | start_time: '9:00AM',
110 | end_time: '10:30AM'
111 | },
112 | {
113 | start_time: '2:00PM',
114 | end_time: '2:30PM'
115 | }
116 | ],
117 | starting_from: '2024-05-23',
118 | ending_at: '2024-06-12',
119 | )
120 | # returns an array of hashes, each with :start and :end keys
121 | #=>
122 | [
123 | {
124 | start: #,
125 | end: #,
126 | },
127 | {
128 | start: #,
129 | end: #,
130 | },
131 | {
132 | start: #,
133 | end: #
134 | },
135 | ...
136 | {
137 | start: #,
138 | end: #
139 | }
140 | ]
141 | ```
142 |
143 | ### Example using the `limit` parameter.
144 |
145 | As a ruby dev, I want to generate the next **3** datetime blocks of **9:00AM - 10:30AM** and **2:00PM - 2:30PM** on **Sundays**, after **May 23, 2024**. I can do this using the `limit` parameter, instead of `ending_at`.
146 |
147 | ```rb
148 | Periodoxical.generate(
149 | time_zone: 'America/Los_Angeles',
150 | days_of_week: %w[sun],
151 | time_blocks: [
152 | {
153 | start_time: '9:00AM',
154 | end_time: '10:30PM'
155 | },
156 | {
157 | start_time: '2:00PM',
158 | end_time: '2:30PM'
159 | }
160 | ],
161 | starting_from: Date.parse('2024-05-23'), # Can also pass in `Date` object.
162 | limit: 3
163 | )
164 | # =>
165 | [
166 | {
167 | start: #,
168 | end: #,
169 | },
170 | {
171 | start: #,
172 | end: #,
173 | },
174 | {
175 | start: #,
176 | end: #,
177 | },
178 | ]
179 | ```
180 |
181 | ### Time blocks that vary between days-of-the-week
182 |
183 | As a ruby dev, I want to generate all the timeblocks between **May 23, 2024** and **June 12, 2024** where the time should be **8AM-9AM** on **Mondays**, but **10:45AM-12:00PM** and **2:00PM-4:00PM** on **Wednesdays**, and **2:30PM-4:15PM** on **Thursdays**. I can do this using the `day_of_week_time_blocks` parameter.
184 |
185 |
186 |

187 |
(image courtesy of Cal.com)
188 |
189 |
190 |
191 |
192 | ```rb
193 | Periodoxical.generate(
194 | time_zone: 'America/Los_Angeles',
195 | starting_from: Date.parse('2024-05-23'), # can also pass in Date objects
196 | ending_at: Date.parse('2024-06-12'), # can also pass in Date objects,
197 | day_of_week_time_blocks: {
198 | mon: [
199 | { start_time: '8:00AM', end_time: '9:00AM' },
200 | ],
201 | wed: [
202 | { start_time: '10:45AM', end_time: '12:00PM' },
203 | { start_time: '2:00PM', end_time: '4:00PM' },
204 | ],
205 | thu: [
206 | { start_time: '2:30PM', end_time: '4:15PM' }
207 | ],
208 | }
209 | )
210 | ```
211 |
212 | ### Specifying time blocks using rules for month(s) and/or day-of-month.
213 |
214 | As a Ruby dev, I want to generate the next 3 timeblocks for **8AM - 9AM** for the **5th** and **10th** day of every month starting from **June**. I can do this using the `days_of_month` parameter.
215 |
216 | ```rb
217 | Periodoxical.generate(
218 | time_zone: 'America/Los_Angeles',
219 | starting_from: '2024-06-01',
220 | limit: 3,
221 | days_of_month: [5, 10],
222 | time_blocks: [
223 | { start_time: '8:00AM', end_time: '9:00AM' },
224 | ],
225 | )
226 | #=>
227 | [
228 | {
229 | start: #,
230 | end: #,
231 | },
232 | {
233 | start: #,
234 | end: #,
235 | },
236 | {
237 | start: #,
238 | end: #,
239 | },
240 | ]
241 | ```
242 |
243 | ### Specify nth day-of-week in month (ie. first Monday of the Month, second Tuesday of the Month, last Friday of Month)
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.
246 |
247 | ```rb
248 | Periodoxical.generate(
249 | time_zone: 'America/Los_Angeles',
250 | starting_from: '2024-06-01',
251 | limit: 5,
252 | nth_day_of_week_in_month: {
253 | mon: [1, 2], # valid values: -1,1,2,3,4,5
254 | fri: [-1], # Use -1 to specify the last Friday of the month.
255 | },
256 | time_blocks: [
257 | { start_time: '8:00AM', end_time: '9:00AM' },
258 | ],
259 | )
260 | # =>
261 | [
262 | {
263 | start: #, # First Monday of June 2024
264 | end: #,
265 | },
266 | {
267 | start: #, # second Monday of June 2024
268 | end: #,
269 | },
270 | {
271 | start: #, # last Friday of June 2024
272 | end: #,
273 | },
274 | {
275 | start: #, # First Monday of July 2024
276 | end: #,
277 | },
278 | {
279 | start: #, # Second Monday of July 2024
280 | end: #,
281 | },
282 | ]
283 | ```
284 |
285 | ### Exclude time blocks using the `exclusion_dates` and `exclusion_times` parameters
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.
287 |
288 | ```rb
289 | Periodoxical.generate(
290 | time_zone: 'America/Los_Angeles',
291 | starting_from: '2024-06-03',
292 | limit: 4,
293 | exclusion_dates: %w(2024-06-10),
294 | day_of_week_time_blocks: {
295 | mon: [
296 | { start_time: '8:00AM', end_time: '9:00AM' },
297 | ],
298 | }
299 | )
300 | # Returns all Monday 8AM - 9AM blocks except for the Monday on June 10, 2024
301 | # =>
302 | [
303 | {
304 | start: #,
305 | end: #,
306 | }
307 | {
308 | start: #,
309 | end: #,
310 | }
311 | {
312 | start: #,
313 | end: #,
314 | }
315 | {
316 | start: #,
317 | end: #,
318 | }
319 | ]
320 | ```
321 |
322 | As a Ruby dev, I want to generate timeblocks for **8AM - 9AM**, and **10AM - 11AM** on **Mondays**, except for those that conflict (meaning overlap) with the time block of **10:30AM - 11:30AM** on the **Monday of June 10, 2024**. I can skip the conflicting time blocks by using the `exclusion_times` parameter.
323 |
324 | ```rb
325 | Periodoxical.generate(
326 | time_zone: 'America/Los_Angeles',
327 | starting_from: '2024-06-03',
328 | limit: 4,
329 | days_of_week: %(mon),
330 | time_blocks: [
331 | { start_time: '8:00AM', end_time: '9:00AM' },
332 | { start_time: '10:00AM', end_time: '11:00AM' },
333 | ],
334 | exclusion_times: [
335 | {
336 | start: '2024-06-10T10:30:00-07:00',
337 | end: '2024-06-10T11:30:00-07:00',
338 | }
339 | ],
340 | )
341 | # =>
342 | [
343 | {
344 | start: #,
345 | end: #,
346 | },
347 | {
348 | start: #,
349 | end: #,
350 | },
351 | {
352 | start: #,
353 | end: #,
354 | },
355 | # The June 10 10AM - 11AM was skipped because it overlapped with the June 10 10:30AM - 11:30AM exclusion time.
356 | {
357 | start: #,
358 | end: #,
359 | },
360 | {
361 | start: #,
362 | end: #,
363 | },
364 | {
365 | start: #,
366 | end: #,
367 | },
368 | ]
369 | ```
370 |
371 | ### Every-other-nth day-of-week rules (ie. every other Tuesday, every 3rd Wednesday, every 10th Friday)
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.
374 |
375 | This can be visualized as:
376 |
377 |
378 |

379 |
(image courtesy of calendar.google.com)
380 |
381 |
382 |
383 |
384 | ```rb
385 | Periodoxical.generate(
386 | time_zone: 'America/Los_Angeles',
387 | starting_from: '2024-12-30',
388 | days_of_week: {
389 | mon: { every: true }, # every Monday (no skipping)
390 | tue: { every_other_nth: 2 }, # every other Tuesday starting at first Tuesday from `starting_from` date
391 | wed: { every_other_nth: 3 }, # every 3rd Wednesday starting at first Wednesday from `starting_from` date
392 | },
393 | limit: 10,
394 | time_blocks: [
395 | { start_time: '9:00AM', end_time: '10:00AM' },
396 | ],
397 | )
398 | #=>
399 | [
400 | {
401 | start: #,
402 | end: #,
403 | },
404 | {
405 | start: #,
406 | end: #,
407 | },
408 | {
409 | start: #,
410 | end: #,
411 | },
412 | {
413 | start: #,
414 | end: #,
415 | },
416 | {
417 | start: #,
418 | end: #,
419 | },
420 | {
421 | start: #,
422 | end: #,
423 | },
424 | {
425 | start: #,
426 | end: #,
427 | },
428 | {
429 | start: #,
430 | end: #,
431 | },
432 | {
433 | start: #,
434 | end: #,
435 | },
436 | {
437 | start: #,
438 | end: #,
439 | }
440 | ]
441 | ```
442 |
443 | ### Use the `duration` key, to automatically partition the provided `time_blocks` into smaller chunks to the given duration.
444 |
445 | As a Ruby dev, I want to generate **30 minute** time blocks between **9:00AM - 1:00PM, and 2:00PM - 5:00PM**. Because it is too tedious to generate all 14 of these time blocks, I prefer to pass in the `duration` key and have `periodoxical` generate them for me.
446 |
447 | N.B. If you provide a duration that conflicts with your time blocks, `periodoxical` will not return any time blocks. For example, if you specify **9:00AM - 10:00AM** but set your **duration** as 90 minutes, no time blocks are generated since we can't fit 90 minutes into an hour!
448 |
449 |
450 | ```rb
451 | Periodoxical.generate(
452 | time_zone: 'America/Los_Angeles',
453 | time_blocks: [
454 | {
455 | start_time: '9:00AM',
456 | end_time: '1:00PM'
457 | },
458 | {
459 | start_time: '2:00PM',
460 | end_time: '5:00PM'
461 | },
462 | ],
463 | duration: 30, #(minutes)
464 | starting_from: '2024-05-23',
465 | ending_at: '2024-05-26',
466 | )
467 | # => [
468 | {
469 | start: #
470 | end: #
471 | },
472 | {
473 | start: #
474 | end: #
475 | },
476 | {
477 | start: #
478 | end: #
479 | },
480 | {
481 | start: #
482 | end: #
483 | }
484 | ]
485 | ```
486 |
487 | ### Having Some Fun
488 |
489 | Generate all the Friday the 13ths ever since May 1980 (when the first Friday the 13th film was released).
490 |
491 | ```rb
492 | Periodoxical.generate(
493 | time_zone: 'America/Los_Angeles',
494 | starting_from: '1980-05-01',
495 | days_of_week: %w(fri),
496 | days_of_month: [13],
497 | limit: 100,
498 | time_blocks: [
499 | { start_time: '11:00PM', end_time: '12:00AM' },
500 | ],
501 | )
502 | # =>
503 | [
504 | {
505 | start: #,
506 | end: #,
507 | },
508 | {
509 | start: #,
510 | end: #,
511 | },
512 | {
513 | start: #,
514 | end: #,
515 | },
516 | {
517 | start: #,
518 | end: #,
519 | }
520 | ...
521 | ]
522 | ```
523 |
524 | Generate the next 10 Thanksgivings from now on (Thanksgivings is defined as the 4th Thursday in November).
525 |
526 | ```rb
527 | Periodoxical.generate(
528 | time_zone: 'America/Los_Angeles',
529 | starting_from: '2024-05-01',
530 | months: [11],
531 | nth_day_of_week_in_month: {
532 | thu: [4],
533 | },
534 | limit: 10,
535 | time_blocks: [
536 | { start_time: '5:00PM', end_time: '6:00PM' },
537 | ],
538 | )
539 | #=>
540 | [
541 | {
542 | start: #,
543 | end: #,
544 | },
545 | {
546 | start: #,
547 | end: #,
548 | },
549 | {
550 | start: #,
551 | end: #,
552 | },
553 | {
554 | start: #,
555 | end: #,
556 | },
557 | {
558 | start: #,
559 | end: #,
560 | },
561 | ...
562 | ]
563 | ```
564 |
565 | ## Development
566 |
567 | 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.
568 |
569 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
570 |
571 | ## Contributing
572 |
573 | Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/periodoxical. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
574 |
575 | ## License
576 |
577 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
578 |
579 | ## Code of Conduct
580 |
581 | Everyone interacting in the Periodoxical project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/periodoxical/blob/master/CODE_OF_CONDUCT.md).
582 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require "rspec/core/rake_task"
3 |
4 | RSpec::Core::RakeTask.new(:spec)
5 |
6 | task :default => :spec
7 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require "bundler/setup"
4 | require "periodoxical"
5 |
6 | # You can add fixtures and/or initialization code here to make experimenting
7 | # with your gem easier. You can also use a different console, if you like.
8 |
9 | # (If you use this, don't forget to add pry to your Gemfile!)
10 | # require "pry"
11 | # Pry.start
12 |
13 | require "irb"
14 | IRB.start(__FILE__)
15 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 |
--------------------------------------------------------------------------------
/lib/periodoxical.rb:
--------------------------------------------------------------------------------
1 | require "periodoxical/version"
2 | require "periodoxical/validation"
3 | require "periodoxical/helpers"
4 | require "date"
5 | require "time"
6 | require "tzinfo"
7 |
8 | module Periodoxical
9 | class << self
10 | def generate(**opts)
11 | Core.new(**opts).generate
12 | end
13 | end
14 |
15 | class Core
16 | include Periodoxical::Validation
17 | include Periodoxical::Helpers
18 | # @param [String] time_zone
19 | # Ex: 'America/Los_Angeles', 'America/Chicago',
20 | # TZInfo::DataTimezone#name from the tzinfo gem (https://github.com/tzinfo/tzinfo)
21 | # @param [Date, String] starting_from
22 | # @param [Date, String] ending_at
23 | # @param [Array] time_blocks
24 | # Ex: [
25 | # {
26 | # start_time: '9:00AM',
27 | # end_time: '10:30PM'
28 | # },
29 | # {
30 | # start_time: '2:00PM',
31 | # end_time: '2:30PM'
32 | # }
33 | # ]
34 | # @param [Array, nil] days_of_week
35 | # Days of the week to generate the times for, if nil, then times are generated
36 | # for every day.
37 | # Ex: %w(mon tue wed sat)
38 | # @param [Array, nil] days_of_month
39 | # Days of month to generate times for.
40 | # Ex: %w(5 10) - The 5th and 10th days of every month
41 | # @param [Array, nil] months
42 | # Months as integers, where 1 = Jan, 12 = Dec
43 | # @param [Integer] limit
44 | # How many date times to generate. To be used when `ending_at` is nil.
45 | # @param [Aray] exclusion_dates
46 | # Dates to be excluded when generating the time blocks
47 | # Ex: ['2024-06-10', '2024-06-14']
48 | # @param [Aray] exclusion_times
49 | # Timeblocks to be excluded when generating the time blocks if there is conflict (ie. overlap)
50 | # Ex: [
51 | # {
52 | # start: '2024-06-10T10:30:00-07:00',
53 | # end: '2024-06-10T11:30:00-07:00'
54 | # },
55 | # {
56 | # start: '2024-06-10T14:30:00-07:00',
57 | # end: '2024-06-10T15:30:00-07:00'
58 | # },
59 | # ]
60 | #
61 | # @param [Hash>] day_of_week_time_blocks
62 | # To be used when hours are different between days of the week
63 | # Ex: {
64 | # mon: [{ start_time: '10:15AM', end_time: '11:35AM' }, { start_time: '9:00AM' }, {end_time: '4:30PM'} ],
65 | # tue: { start_time: '11:30PM', end_time: '12:00AM' },
66 | # fri: { start_time: '7:00PM', end_time: '9:00PM' },
67 | # }
68 | # @param [Integer] duration
69 | # Splits the time_blocks into this duration (in minutes). For example, if time_block is 9:00AM - 10:00AM,
70 | # and duration is 20 minutes. It creates 3 timeblocks of:
71 | # - 9:00AM - 9:20AM
72 | # - 9:20AM - 9:40AM
73 | # - 9:40AM - 10:00AM
74 | def initialize(
75 | starting_from:,
76 | ending_at: nil,
77 | time_blocks: nil,
78 | day_of_week_time_blocks: nil,
79 | limit: nil,
80 | exclusion_dates: nil,
81 | exclusion_times: nil,
82 | time_zone: 'Etc/UTC',
83 | days_of_week: nil,
84 | nth_day_of_week_in_month: nil,
85 | days_of_month: nil,
86 | duration: nil,
87 | months: nil
88 | )
89 |
90 | @time_zone = TZInfo::Timezone.get(time_zone)
91 | if days_of_week.is_a?(Array)
92 | @days_of_week = deep_symbolize_keys(days_of_week)
93 | elsif days_of_week.is_a?(Hash)
94 | @days_of_week_with_alternations = deep_symbolize_keys(days_of_week)
95 | end
96 | @nth_day_of_week_in_month = deep_symbolize_keys(nth_day_of_week_in_month)
97 | @days_of_month = days_of_month
98 | @months = months
99 | @time_blocks = deep_symbolize_keys(time_blocks)
100 | @day_of_week_time_blocks = deep_symbolize_keys(day_of_week_time_blocks)
101 | @starting_from = date_object_from(starting_from)
102 | @ending_at = date_object_from(ending_at)
103 | @limit = limit
104 |
105 | if duration
106 | unless duration.is_a?(Integer)
107 | raise "duration must be an integer"
108 | else
109 | @duration = duration
110 | end
111 | end
112 | @exclusion_dates = if exclusion_dates && !exclusion_dates.empty?
113 | exclusion_dates.map { |ed| Date.parse(ed) }
114 | end
115 | @exclusion_times = if exclusion_times
116 | deep_symbolize_keys(exclusion_times).map do |et|
117 | { start: DateTime.parse(et[:start]), end: DateTime.parse(et[:end]) }
118 | end
119 | end
120 | validate!
121 | end
122 |
123 | # @return [Array>]
124 | # Ex: [
125 | # {
126 | # start: #,
127 | # end: #,
128 | # },
129 | # {
130 | # start: #,
131 | # end: #,
132 | # },
133 | # ]
134 | def generate
135 | initialize_looping_variables!
136 | while @keep_generating
137 | if should_add_time_blocks_from_current_date?
138 | add_time_blocks_from_current_date!
139 | end
140 | advance_current_date_and_check_if_reached_end_date
141 | end
142 | @output
143 | end
144 |
145 | private
146 |
147 | # @param [String] time_str
148 | # Ex: '9:00AM'
149 | # @param [Date] date
150 | def time_str_to_object(date, time_str)
151 | time = Time.strptime(time_str, '%I:%M%p')
152 | date_time = DateTime.new(
153 | date.year,
154 | date.month,
155 | date.day,
156 | time.hour,
157 | time.min,
158 | time.sec,
159 | )
160 | @time_zone.local_to_utc(date_time).new_offset(@time_zone.current_period.offset.utc_total_offset)
161 | end
162 |
163 | # @param [Date] date
164 | # @return [Boolean]
165 | # Whether or not the date is excluded
166 | def excluded_date?(date)
167 | return false unless @exclusion_dates
168 |
169 | @exclusion_dates.each do |ed|
170 | return true if date == ed
171 | end
172 |
173 | false
174 | end
175 |
176 | # Variables which manage flow of looping through time and generating slots
177 | def initialize_looping_variables!
178 | @output = []
179 | if @starting_from.is_a?(DateTime)
180 | @current_date = @starting_from.to_date
181 | else
182 | @current_date = @starting_from
183 | end
184 | @current_day_of_week = day_of_week_long_to_short(@current_date.strftime("%A"))
185 | @current_count = 0
186 | @keep_generating = true
187 | # When there are alternations in days of week
188 | # (ie. every other Monday, every 3rd Thursday, etc).
189 | # We keep running tally of day_of_week counts and use modulo-math to pick out
190 | # every n-th one.
191 | if @days_of_week_with_alternations
192 | @days_of_week_running_tally = {
193 | mon: 0,
194 | tue: 0,
195 | wed: 0,
196 | thu: 0,
197 | fri: 0,
198 | sat: 0,
199 | sun: 0,
200 | }
201 | end
202 | end
203 |
204 | # @param [Hash] time_block
205 | # Ex:
206 | # {
207 | # start_time: "9:00AM",
208 | # start_time: "10:00AM",
209 | # }
210 | # Generates time block but also checks if we should stop generating
211 | def append_to_output_and_check_limit(time_block)
212 | strtm = time_str_to_object(@current_date, time_block[:start_time])
213 | endtm = time_str_to_object(@current_date, time_block[:end_time])
214 |
215 | if @duration
216 | split_by_duration_and_append(strtm, endtm)
217 | else
218 | # Check if this particular time is conflicts with any times from `exclusion_times`.
219 | return if before_starting_from_or_after_ending_at?(time_block)
220 | return if overlaps_with_an_excluded_time?({ start: strtm, end: endtm })
221 |
222 | @output << {
223 | start: strtm,
224 | end: endtm
225 | }
226 | increment_and_check_limit
227 | end
228 | end
229 |
230 | # increment count, if `limit` is used to stop generating
231 | def increment_and_check_limit
232 | return unless @limit
233 |
234 | @current_count += 1
235 | return if @current_count < @limit
236 |
237 | @keep_generating = false
238 | throw :done
239 | end
240 |
241 | def advance_current_date_and_check_if_reached_end_date
242 | @current_date = @current_date + 1
243 |
244 | @current_day_of_week = day_of_week_long_to_short(@current_date.strftime("%A"))
245 |
246 | if @ending_at && (@current_date > @ending_at)
247 | @keep_generating = false
248 | end
249 |
250 | # kill switch to stop infinite loop when `limit` is used but
251 | # there is bug, or poorly specified rules. If @current_date goes into
252 | # 1000 years in the future, but still no dates have been generated yet, this is
253 | # most likely an infinite loop situation, and needs to be killed.
254 | if @limit && ((@current_date - @starting_from).to_i > 365000) && @output.empty?
255 | raise "No end condition detected, causing infinite loop. Please check rules/conditions or raise github issue for potential bug fixed"
256 | end
257 | end
258 |
259 | # @return [Boolean]
260 | # Should time blocks be added based on the current_date?
261 | def should_add_time_blocks_from_current_date?
262 | # return false if current_date is explicitly excluded
263 | if @exclusion_dates
264 | return false if @exclusion_dates.include?(@current_date)
265 | end
266 |
267 | # If months are specified, but current_date does not satisfy months,
268 | # return false
269 | if @months
270 | return false unless @months.include?(@current_date.month)
271 | end
272 |
273 | # If days of months are specified, but current_date does not satisfy it,
274 | # return false
275 | if @days_of_month
276 | return false unless @days_of_month.include?(@current_date.day)
277 | end
278 |
279 | # If days of week are specified, but current_date does not satisfy it,
280 | # return false
281 | if @days_of_week
282 | return false unless @days_of_week.include?(@current_day_of_week)
283 | end
284 |
285 | if @days_of_week_with_alternations
286 | # current_date is not specified in days_of_week, so skip it
287 | return false if @days_of_week_with_alternations[@current_day_of_week.to_sym].nil?
288 |
289 | alternating_spec = @days_of_week_with_alternations[@current_day_of_week.to_sym]
290 |
291 | # In the { every: true } case, we don't check the alternations logic, we just add it.
292 | unless alternating_spec[:every]
293 | # We are now specifying every other nth occurrence (ie. every 2nd Tuesday, every 3rd Wednesday)
294 | alternating_frequency = alternating_spec[:every_other_nth]
295 |
296 | unless (@days_of_week_running_tally[@current_day_of_week.to_sym] % alternating_frequency) == 0
297 | # If day-of-week alternations are present, we need to keep track of day-of-weeks
298 | # we have encountered and added or would have added so far.
299 | update_days_of_week_running_tally!
300 |
301 | return false
302 | end
303 | update_days_of_week_running_tally!
304 | end
305 | end
306 |
307 | if @day_of_week_time_blocks
308 | dowtb = @day_of_week_time_blocks[@current_day_of_week.to_sym]
309 | return false if dowtb.nil?
310 | return false if dowtb.empty?
311 | end
312 |
313 | if @nth_day_of_week_in_month
314 | # If the day of week is specified in nth_day_of_week_in_month,
315 | # we need to investigate it whether or not to exclude it.
316 | if @nth_day_of_week_in_month[@current_day_of_week.to_sym]
317 | n_occurence_of_day_of_week_in_month = ((@current_date.day - 1) / 7) + 1
318 | # -1 is a special case and requires extra-math
319 | if @nth_day_of_week_in_month[@current_day_of_week.to_sym].include?(-1)
320 | # We basically want to convert the -1 into its 'positive-equivalent` in this month, and compare it with that.
321 | # For example, in June 2024, the Last Friday is also the 4th Friday. So in that case, we convert the -1 into a 4.
322 | positivized_indices = @nth_day_of_week_in_month[@current_day_of_week.to_sym].map { |indx| positivize_index(indx, @current_day_of_week) }
323 | return positivized_indices.include?(n_occurence_of_day_of_week_in_month)
324 | else
325 | return @nth_day_of_week_in_month[@current_day_of_week.to_sym].include?(n_occurence_of_day_of_week_in_month)
326 | end
327 | else
328 | return false
329 | end
330 | end
331 |
332 | # The default return true is really only needed to support this use-case:
333 | # Periodoxical.generate(
334 | # time_zone: 'America/Los_Angeles',
335 | # time_blocks: [
336 | # {
337 | # start_time: '9:00AM',
338 | # end_time: '10:30AM'
339 | # },
340 | # ],
341 | # starting_from: '2024-05-23',
342 | # ending_at: '2024-05-27',
343 | # )
344 | # where if we don't specify any date-of-week/month constraints, we return all consecutive dates.
345 | # In the future, if we don't support this case, we can use `false` as the return value.
346 | true
347 | end
348 |
349 | def add_time_blocks_from_current_date!
350 | if @day_of_week_time_blocks
351 | time_blocks = @day_of_week_time_blocks[@current_day_of_week.to_sym]
352 | catch :done do
353 | time_blocks.each do |tb|
354 | append_to_output_and_check_limit(tb)
355 | end
356 | end
357 | elsif @time_blocks
358 | catch :done do
359 | @time_blocks.each do |tb|
360 | append_to_output_and_check_limit(tb)
361 | end
362 | end
363 | end
364 | end
365 |
366 | # What is the positive index of the last day-of-week for the given month-year?
367 | # For example, the last Friday in June 2024 is also the nth Friday. What is this n?
368 | # @return [Integer]
369 | def positivize_index(indx, day_of_week)
370 | # If index is already positive, just return it
371 | return indx if indx > 0
372 |
373 | # get last_day_of month
374 | month = @current_date.month
375 | year = @current_date.year
376 | last_date = Date.new(year, month, -1)
377 |
378 | # walk backwords until you get to the right day of the week
379 | while day_of_week_long_to_short(last_date.strftime("%A")) != day_of_week
380 | last_date = last_date - 1
381 | end
382 |
383 | ((last_date.day - 1) / 7) + 1
384 | end
385 |
386 | def update_days_of_week_running_tally!
387 | @days_of_week_running_tally[@current_day_of_week.to_sym] = @days_of_week_running_tally[@current_day_of_week.to_sym] + 1
388 | end
389 |
390 | # @return [Boolean]
391 | # Used only when `starting_from` and `ending_at` are instances of DateTime
392 | # instead of Date, requiring more precision, calculation.
393 | def before_starting_from_or_after_ending_at?(time_block)
394 | return false unless @starting_from.is_a?(DateTime) || @ending_at.is_a?(DateTime)
395 |
396 | if @starting_from.is_a?(DateTime)
397 | start_time = time_str_to_object(@current_date, time_block[:start_time])
398 |
399 | # If the candidate time block is starting earlier than @starting_from, we want to skip it
400 | return true if start_time < @starting_from
401 | end
402 |
403 | if @ending_at.is_a?(DateTime)
404 | end_time = time_str_to_object(@current_date, time_block[:end_time])
405 |
406 | # If the candidate time block is ending after @ending_at, we want to skip it
407 | return true if end_time > @ending_at
408 | end
409 |
410 | false
411 | end
412 |
413 | # @param [Hash] time_block
414 | # Ex:
415 | # {
416 | # start: #,
417 | # end: #,
418 | # }
419 | # @return [Boolean]
420 | # Whether or not the given `time_block` in the @current_date and
421 | # @time_zone overlaps with the times in `exclusion_times`.
422 | def overlaps_with_an_excluded_time?(tm_blck)
423 | return false unless @exclusion_times
424 |
425 | @exclusion_times.each do |exclusion_timeblock|
426 | return true if overlap?(
427 | exclusion_timeblock,
428 | tm_blck
429 | )
430 | end
431 |
432 | false
433 | end
434 |
435 | def split_by_duration_and_append(strtm, endtm)
436 | delta = Rational(@duration, 24 * 60)
437 | si = strtm
438 | ei = strtm + delta
439 | while ei <= endtm
440 | unless overlaps_with_an_excluded_time?({ start: si, end: ei })
441 | @output << {
442 | start: si,
443 | end: ei
444 | }
445 | increment_and_check_limit
446 | end
447 | si += delta
448 | ei += delta
449 | end
450 | end
451 | end
452 | end
453 |
--------------------------------------------------------------------------------
/lib/periodoxical/helpers.rb:
--------------------------------------------------------------------------------
1 | module Periodoxical
2 | module Helpers
3 | def deep_symbolize_keys(obj)
4 | return unless obj
5 |
6 | case obj
7 | when Hash
8 | obj.each_with_object({}) do |(key, value), result|
9 | symbolized_key = key.to_sym rescue key
10 | result[symbolized_key] = deep_symbolize_keys(value)
11 | end
12 | when Array
13 | obj.map { |e| deep_symbolize_keys(e) }
14 | else
15 | obj
16 | end
17 | end
18 |
19 | # @param [Hash] time_block_1, time_block_2
20 | # Ex: {
21 | # start: #,
22 | # end: #,
23 | # }
24 | def overlap?(time_block_1, time_block_2)
25 | tb_1_start = time_block_1[:start]
26 | tb_1_end = time_block_1[:end]
27 | tb_2_start = time_block_2[:start]
28 | tb_2_end = time_block_2[:end]
29 |
30 | # Basically overlap is when one starts before the other has ended
31 | return true if tb_1_end > tb_2_start && tb_1_end < tb_2_end
32 | # By symmetry
33 | return true if tb_2_end > tb_1_start && tb_2_end < tb_1_end
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 |
38 | false
39 | end
40 |
41 | def date_object_from(dt)
42 | return unless dt
43 | return dt if dt.is_a?(Date) || dt.is_a?(DateTime)
44 |
45 | if dt.is_a?(String)
46 | return Date.parse(dt) if /\A\d{4}-(0?[1-9]|1[0-2])-(0?[1-9]|[12]\d|3[01])\z/ =~ dt
47 |
48 | if /\A\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(Z|[+-][01]\d:[0-5]\d)?\z/ =~ dt
49 | # convert to DateTime object
50 | dt = DateTime.parse(dt)
51 | # convert to given time_zone
52 | return dt.to_time.localtime(@time_zone.utc_offset).to_datetime
53 | end
54 |
55 | raise "Could not parse date/datetime string #{dt}. Please README for examples."
56 | else
57 | raise "Invalid argument: #{dt}"
58 | end
59 | end
60 |
61 | def day_of_week_long_to_short(dow)
62 | {
63 | "Monday" => "mon",
64 | "Tuesday" => "tue",
65 | "Wednesday" => "wed",
66 | "Thursday" => "thu",
67 | "Friday" => "fri",
68 | "Saturday" => "sat",
69 | "Sunday" => "sun",
70 | }[dow]
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/periodoxical/validation.rb:
--------------------------------------------------------------------------------
1 | module Periodoxical
2 | module Validation
3 | VALID_DAYS_OF_WEEK = %w[mon tue wed thu fri sat sun].freeze
4 | def validate!
5 | unless @day_of_week_time_blocks || @time_blocks
6 | raise "`day_of_week_time_blocks` or `time_blocks` need to be provided"
7 | end
8 |
9 | if (@days_of_week || @days_of_week_with_alternations) && @day_of_week_time_blocks
10 | raise "`days_of_week` and `day_of_week_time_blocks` are both provided, which leads to ambiguity. Please use only one of these parameters."
11 | end
12 |
13 | if @weeks_of_month
14 | @weeks_of_month.each do |wom|
15 | unless wom.is_a?(Integer) && wom.between?(1, 5)
16 | raise "weeks_of_month must be an array of integers between 1 and 5"
17 | end
18 | end
19 | end
20 |
21 | # days of week are valid
22 | if @days_of_week
23 | @days_of_week.each do |day|
24 | unless VALID_DAYS_OF_WEEK.include?(day.to_s)
25 | raise "#{day} is not valid day of week format. Must be: #{VALID_DAYS_OF_WEEK}"
26 | end
27 | end
28 | end
29 |
30 | if @days_of_week_with_alternations
31 | @days_of_week_with_alternations.each do |dow, every_other|
32 | unless VALID_DAYS_OF_WEEK.include?(dow.to_s)
33 | raise "#{dow} is not valid day of week format. Must be: #{VALID_DAYS_OF_WEEK}"
34 | end
35 | unless every_other.is_a?(Hash)
36 | raise "days_of_week parameter is not used correctly. Please look at examples in README."
37 | end
38 | unless every_other[:every] || every_other[:every_other_nth]
39 | raise "days_of_week parameter is not used correctly. Please look at examples in README."
40 | end
41 | if every_other[:every_other_nth]
42 | unless every_other[:every_other_nth].is_a?(Integer)
43 | raise "days_of_week parameter is not used correctly. Please look at examples in README."
44 | end
45 |
46 | unless every_other[:every_other_nth] > 1
47 | raise "days_of_week parameter is not used correctly. Please look at examples in README."
48 | end
49 | end
50 | end
51 | end
52 |
53 | if @nth_day_of_week_in_month
54 | @nth_day_of_week_in_month.keys.each do |day|
55 | unless VALID_DAYS_OF_WEEK.include?(day.to_s)
56 | raise "#{day} is not valid day of week format. Must be: #{VALID_DAYS_OF_WEEK}"
57 | end
58 | end
59 | @nth_day_of_week_in_month.each do |k,v|
60 | unless v.is_a?(Array)
61 | raise "nth_day_of_week_in_month parameter is invalid. Please look at the README for examples."
62 | end
63 | v.each do |num|
64 | unless [-1,1,2,3,4,5].include?(num)
65 | raise "nth_day_of_week_in_month parameter is invalid. Please look at the README for examples. "
66 | end
67 | end
68 | end
69 | end
70 |
71 | if @nth_day_of_week_in_month && (@days_of_week || @days_of_week_with_alternations || @day_of_week_time_blocks)
72 | raise "nth_day_of_week_in_month parameter cannot be used in combination with `days_of_week` or `day_of_week_time_blocks`. Please look at the README for examples."
73 | end
74 |
75 | if @day_of_week_time_blocks
76 | @day_of_week_time_blocks.keys.each do |d|
77 | unless VALID_DAYS_OF_WEEK.include?(d.to_s)
78 | raise "#{d} is not a valid day of week format. Must be #{VALID_DAYS_OF_WEEK}"
79 | end
80 | end
81 | end
82 |
83 | if @days_of_month
84 | @days_of_month.each do |dom|
85 | unless dom.is_a?(Integer) && dom.between?(1,31)
86 | raise 'days_of_months must be array of integers between 1 and 31'
87 | end
88 | end
89 | end
90 |
91 | if @months
92 | @months.each do |mon|
93 | unless mon.is_a?(Integer) && mon.between?(1, 12)
94 | raise 'months must be array of integers between 1 and 12'
95 | end
96 | end
97 | end
98 |
99 | unless( @limit || @ending_at)
100 | raise "Either `limit` or `ending_at` must be provided"
101 | end
102 |
103 | if @exclusion_times
104 | @exclusion_times.each do |tb|
105 | unless tb[:start] < tb[:end]
106 | raise "Exclusion times must have `start` before `end`. #{tb[:start]} not before #{tb[:end]}"
107 | end
108 | end
109 | end
110 | end
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/lib/periodoxical/version.rb:
--------------------------------------------------------------------------------
1 | module Periodoxical
2 | VERSION = "2.2.1"
3 | end
4 |
--------------------------------------------------------------------------------
/periodoxical.gemspec:
--------------------------------------------------------------------------------
1 |
2 | lib = File.expand_path("../lib", __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require "periodoxical/version"
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "periodoxical"
8 | spec.version = Periodoxical::VERSION
9 | spec.authors = ["Steven Li"]
10 | spec.email = ["stevenji@gmail.com"]
11 |
12 | spec.summary = %q{Generating date/times based on rules. Perfect for (but not limited to) calendar/scheduling applications.}
13 | spec.description = %q{Generating date/times based on rules. Perfect for (but not limited to) calendar/scheduling applications. Generate times based on rules like days of the week, timezones, time blocks, start dates, end dates, etc.}
14 | spec.homepage = "https://github.com/StevenJL/periodoxical"
15 | spec.license = "MIT"
16 |
17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18 | # to allow pushing to a single host or delete this section to allow pushing to any host.
19 | if spec.respond_to?(:metadata)
20 | spec.metadata["homepage_uri"] = spec.homepage
21 | spec.metadata["source_code_uri"] = "https://github.com/StevenJL/periodoxical"
22 | spec.metadata["changelog_uri"] = "https://github.com/StevenJL/periodoxical"
23 | else
24 | raise "RubyGems 2.0 or newer is required to protect against " \
25 | "public gem pushes."
26 | end
27 |
28 | # Specify which files should be added to the gem when it is released.
29 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
30 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
31 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
32 | end
33 | spec.bindir = "exe"
34 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
35 | spec.require_paths = ["lib"]
36 |
37 | spec.add_dependency 'tzinfo', '~> 2.0', '>= 2.0.0'
38 |
39 | spec.add_development_dependency "bundler", "~> 2.4"
40 | spec.add_development_dependency "rake", "~> 12.3.3"
41 | spec.add_development_dependency "rspec", "~> 3.0"
42 | spec.add_development_dependency "pry-byebug", "~> 3.10"
43 | spec.add_development_dependency "pry-stack_explorer", "~> 0.6"
44 |
45 | spec.required_ruby_version = '>= 3.0.0'
46 | end
47 |
--------------------------------------------------------------------------------
/spec/periodoxical_spec.rb:
--------------------------------------------------------------------------------
1 | require "pry"
2 | require "date"
3 |
4 | RSpec.describe Periodoxical do
5 | def human_readable(time_blocks)
6 | timezone = TZInfo::Timezone.get('America/Los_Angeles')
7 | time_blocks_str = time_blocks.map do |time_block|
8 | start_time = time_block[:start]
9 | end_time = time_block[:end]
10 | start_time_converted = timezone.utc_to_local(start_time.new_offset(0))
11 | end_time_converted = timezone.utc_to_local(end_time.new_offset(0))
12 | {
13 | start: start_time_converted.strftime('%Y-%m-%d %H:%M:%S %z'),
14 | end: end_time_converted.strftime('%Y-%m-%d %H:%M:%S %z'),
15 | }
16 | end
17 | end
18 |
19 | it "has a version number" do
20 | expect(Periodoxical::VERSION).not_to be nil
21 | end
22 |
23 | describe '.generate' do
24 | context 'validation' do
25 | context 'when no time_blocks or day_of_week_time_blocks is provided' do
26 | subject do
27 | Periodoxical.generate(
28 | time_zone: 'America/Los_Angeles',
29 | starting_from: '2024-05-23',
30 | ending_at: '2024-05-27',
31 | )
32 | end
33 |
34 | it 'raises error' do
35 | expect { subject }.to raise_error
36 | end
37 | end
38 | end
39 |
40 | context 'when only time_blocks are provided' do
41 | subject do
42 | Periodoxical.generate(
43 | time_zone: 'America/Los_Angeles',
44 | time_blocks: [
45 | {
46 | start_time: '9:00AM',
47 | end_time: '10:30AM'
48 | },
49 | ],
50 | starting_from: '2024-05-23',
51 | ending_at: '2024-05-27',
52 | )
53 | end
54 |
55 | it 'generates correct time blocks' do
56 | time_blocks = human_readable(subject)
57 |
58 | expect(time_blocks).to eq(
59 | [
60 | {
61 | :start=>"2024-05-23 09:00:00 -0700",
62 | :end=>"2024-05-23 10:30:00 -0700",
63 | },
64 | {
65 | :start=>"2024-05-24 09:00:00 -0700",
66 | :end=>"2024-05-24 10:30:00 -0700"
67 | },
68 | {
69 | :start=>"2024-05-25 09:00:00 -0700",
70 | :end=>"2024-05-25 10:30:00 -0700"
71 | },
72 | {
73 | :start=>"2024-05-26 09:00:00 -0700",
74 | :end=>"2024-05-26 10:30:00 -0700"
75 | },
76 | {
77 | :start=>"2024-05-27 09:00:00 -0700",
78 | :end=>"2024-05-27 10:30:00 -0700"
79 | }
80 | ]
81 | )
82 | end
83 | end
84 |
85 | context 'when iso8601 and DateTime time range is provided' do
86 | subject do
87 | Periodoxical.generate(
88 | time_zone: 'America/Los_Angeles',
89 | time_blocks: [
90 | {
91 | start_time: '9:00AM',
92 | end_time: '10:30AM'
93 | },
94 | ],
95 | starting_from: '2024-05-23T09:30:00-07:00',
96 | ending_at: '2024-05-27T10:00:00-07:00',
97 | )
98 | end
99 |
100 | it 'returns the correct time blocks' do
101 | time_blocks = human_readable(subject)
102 |
103 | expect(time_blocks).to eq(
104 | [
105 | {
106 | :start=>"2024-05-24 09:00:00 -0700",
107 | :end=>"2024-05-24 10:30:00 -0700"
108 | },
109 | {
110 | :start=>"2024-05-25 09:00:00 -0700",
111 | :end=>"2024-05-25 10:30:00 -0700"
112 | },
113 | {
114 | :start=>"2024-05-26 09:00:00 -0700",
115 | :end=>"2024-05-26 10:30:00 -0700"
116 | }
117 | ]
118 | )
119 | end
120 | end
121 |
122 | context 'when using days_of_weeks and time_blocks' do
123 | subject do
124 | Periodoxical.generate(
125 | time_zone: 'America/Los_Angeles',
126 | days_of_week: %w[mon wed thu],
127 | time_blocks: [
128 | {
129 | start_time: '9:00AM',
130 | end_time: '10:30PM'
131 | },
132 | {
133 | start_time: '2:00PM',
134 | end_time: '2:30PM'
135 | }
136 | ],
137 | starting_from: '2024-05-23',
138 | ending_at: '2024-06-12',
139 | )
140 | end
141 |
142 | it "generates all the times 9:00AM to 10:30PM, 2:00PM to 2:30PM on Mondays, Wednesdays, and Thursdays, between May 23, 2024 and June 12, 2024" do
143 | time_blocks = human_readable(subject)
144 | expect(time_blocks).to eq(
145 | [
146 | {
147 | :start=>"2024-05-23 09:00:00 -0700",
148 | :end=>"2024-05-23 22:30:00 -0700"
149 | },
150 | {
151 | :start=>"2024-05-23 14:00:00 -0700",
152 | :end=>"2024-05-23 14:30:00 -0700"
153 | },
154 | {
155 | :start=>"2024-05-27 09:00:00 -0700",
156 | :end=>"2024-05-27 22:30:00 -0700"
157 | },
158 | {
159 | :start=>"2024-05-27 14:00:00 -0700",
160 | :end=>"2024-05-27 14:30:00 -0700"
161 | },
162 | {
163 | :start=>"2024-05-29 09:00:00 -0700",
164 | :end=>"2024-05-29 22:30:00 -0700"
165 | },
166 | {
167 | :start=>"2024-05-29 14:00:00 -0700",
168 | :end=>"2024-05-29 14:30:00 -0700"
169 | },
170 | {
171 | :start=>"2024-05-30 09:00:00 -0700",
172 | :end=>"2024-05-30 22:30:00 -0700"
173 | },
174 | {
175 | :start=>"2024-05-30 14:00:00 -0700",
176 | :end=>"2024-05-30 14:30:00 -0700"
177 | },
178 | {
179 | :start=>"2024-06-03 09:00:00 -0700",
180 | :end=>"2024-06-03 22:30:00 -0700"
181 | },
182 | {
183 | :start=>"2024-06-03 14:00:00 -0700",
184 | :end=>"2024-06-03 14:30:00 -0700"
185 | },
186 | {
187 | :start=>"2024-06-05 09:00:00 -0700",
188 | :end=>"2024-06-05 22:30:00 -0700"
189 | },
190 | {
191 | :start=>"2024-06-05 14:00:00 -0700",
192 | :end=>"2024-06-05 14:30:00 -0700"
193 | },
194 | {
195 | :start=>"2024-06-06 09:00:00 -0700",
196 | :end=>"2024-06-06 22:30:00 -0700"
197 | },
198 | {
199 | :start=>"2024-06-06 14:00:00 -0700",
200 | :end=>"2024-06-06 14:30:00 -0700"
201 | },
202 | {
203 | :start=>"2024-06-10 09:00:00 -0700",
204 | :end=>"2024-06-10 22:30:00 -0700"
205 | },
206 | {
207 | :start=>"2024-06-10 14:00:00 -0700",
208 | :end=>"2024-06-10 14:30:00 -0700"
209 | },
210 | {
211 | :start=>"2024-06-12 09:00:00 -0700",
212 | :end=>"2024-06-12 22:30:00 -0700"
213 | },
214 | {
215 | :start=>"2024-06-12 14:00:00 -0700",
216 | :end=>"2024-06-12 14:30:00 -0700"
217 | },
218 | ]
219 | )
220 | end
221 | end
222 |
223 | context 'when using limit' do
224 | subject do
225 | Periodoxical.generate(
226 | time_zone: 'America/Los_Angeles',
227 | days_of_week: %w[sun],
228 | time_blocks: [
229 | {
230 | start_time: '9:00AM',
231 | end_time: '10:30PM'
232 | },
233 | {
234 | start_time: '2:00PM',
235 | end_time: '2:30PM'
236 | }
237 | ],
238 | starting_from: Date.parse('2024-05-23'),
239 | limit: 5
240 | )
241 | end
242 |
243 | it "generates 5 all the times 9:00AM to 10:30PM, 2:00PM to 2:30PM on Sundays" do
244 | time_blocks = human_readable(subject)
245 | expect(time_blocks).to eq(
246 | [
247 | {
248 | :start=>"2024-05-26 09:00:00 -0700",
249 | :end=>"2024-05-26 22:30:00 -0700"
250 | },
251 | {
252 | :start=>"2024-05-26 14:00:00 -0700",
253 | :end=>"2024-05-26 14:30:00 -0700"
254 | },
255 | {
256 | :start=>"2024-06-02 09:00:00 -0700",
257 | :end=>"2024-06-02 22:30:00 -0700"
258 | },
259 | {
260 | :start=>"2024-06-02 14:00:00 -0700",
261 | :end=>"2024-06-02 14:30:00 -0700"
262 | },
263 | {
264 | :start=>"2024-06-09 09:00:00 -0700",
265 | :end=>"2024-06-09 22:30:00 -0700"
266 | }
267 | ]
268 | )
269 | end
270 | end
271 |
272 | context 'when time blocks varies between days' do
273 | subject do
274 | Periodoxical.generate(
275 | time_zone: 'America/Los_Angeles',
276 | starting_from: Date.parse('2024-05-23'),
277 | ending_at: Date.parse('2024-06-12'),
278 | day_of_week_time_blocks: {
279 | mon: [
280 | { start_time: '8:00AM', end_time: '9:00AM' },
281 | ],
282 | wed: [
283 | { start_time: '10:45AM', end_time: '12:00PM' },
284 | { start_time: '2:00PM', end_time: '4:00PM' },
285 | ],
286 | thu: [
287 | { start_time: '2:30PM', end_time: '4:15PM' }
288 | ],
289 | }
290 | )
291 | end
292 |
293 | it 'generates correct time blocks' do
294 | time_blocks = human_readable(subject)
295 |
296 | expect(time_blocks).to eq(
297 | [
298 | {
299 | :start=>"2024-05-23 14:30:00 -0700",
300 | :end=>"2024-05-23 16:15:00 -0700"
301 | },
302 | {
303 | :start=>"2024-05-27 08:00:00 -0700",
304 | :end=>"2024-05-27 09:00:00 -0700"
305 | },
306 | {
307 | :start=>"2024-05-29 10:45:00 -0700",
308 | :end=>"2024-05-29 12:00:00 -0700"
309 | },
310 | {
311 | :start=>"2024-05-29 14:00:00 -0700",
312 | :end=>"2024-05-29 16:00:00 -0700"
313 | },
314 | {
315 | :start=>"2024-05-30 14:30:00 -0700",
316 | :end=>"2024-05-30 16:15:00 -0700"
317 | },
318 | {
319 | :start=>"2024-06-03 08:00:00 -0700",
320 | :end=>"2024-06-03 09:00:00 -0700"
321 | },
322 | {
323 | :start=>"2024-06-05 10:45:00 -0700",
324 | :end=>"2024-06-05 12:00:00 -0700"
325 | },
326 | {
327 | :start=>"2024-06-05 14:00:00 -0700",
328 | :end=>"2024-06-05 16:00:00 -0700"
329 | },
330 | {
331 | :start=>"2024-06-06 14:30:00 -0700",
332 | :end=>"2024-06-06 16:15:00 -0700"
333 | },
334 | {
335 | :start=>"2024-06-10 08:00:00 -0700",
336 | :end=>"2024-06-10 09:00:00 -0700"
337 | },
338 | {
339 | :start=>"2024-06-12 10:45:00 -0700",
340 | :end=>"2024-06-12 12:00:00 -0700"
341 | },
342 | {
343 | :start=>"2024-06-12 14:00:00 -0700",
344 | :end=>"2024-06-12 16:00:00 -0700"
345 | }
346 | ]
347 | )
348 | end
349 |
350 | context 'when day_of_week_time_blocks parameter has string keys' do
351 | subject do
352 | Periodoxical.generate(
353 | time_zone: 'America/Los_Angeles',
354 | starting_from: Date.parse('2024-05-23'),
355 | ending_at: Date.parse('2024-06-12'),
356 | day_of_week_time_blocks: {
357 | 'mon' => [
358 | { 'start_time' => '8:00AM', 'end_time' => '9:00AM' },
359 | ],
360 | 'wed' => [
361 | { 'start_time' => '10:45AM', 'end_time' => '12:00PM' },
362 | { 'start_time' => '2:00PM', 'end_time' => '4:00PM' },
363 | ],
364 | 'thu' => [
365 | { 'start_time' => '2:30PM', 'end_time' => '4:15PM' }
366 | ],
367 | }
368 | )
369 | end
370 |
371 | it 'generates correct time blocks' do
372 | time_blocks = human_readable(subject)
373 |
374 | expect(time_blocks).to eq(
375 | [
376 | {
377 | :start=>"2024-05-23 14:30:00 -0700",
378 | :end=>"2024-05-23 16:15:00 -0700"
379 | },
380 | {
381 | :start=>"2024-05-27 08:00:00 -0700",
382 | :end=>"2024-05-27 09:00:00 -0700"
383 | },
384 | {
385 | :start=>"2024-05-29 10:45:00 -0700",
386 | :end=>"2024-05-29 12:00:00 -0700"
387 | },
388 | {
389 | :start=>"2024-05-29 14:00:00 -0700",
390 | :end=>"2024-05-29 16:00:00 -0700"
391 | },
392 | {
393 | :start=>"2024-05-30 14:30:00 -0700",
394 | :end=>"2024-05-30 16:15:00 -0700"
395 | },
396 | {
397 | :start=>"2024-06-03 08:00:00 -0700",
398 | :end=>"2024-06-03 09:00:00 -0700"
399 | },
400 | {
401 | :start=>"2024-06-05 10:45:00 -0700",
402 | :end=>"2024-06-05 12:00:00 -0700"
403 | },
404 | {
405 | :start=>"2024-06-05 14:00:00 -0700",
406 | :end=>"2024-06-05 16:00:00 -0700"
407 | },
408 | {
409 | :start=>"2024-06-06 14:30:00 -0700",
410 | :end=>"2024-06-06 16:15:00 -0700"
411 | },
412 | {
413 | :start=>"2024-06-10 08:00:00 -0700",
414 | :end=>"2024-06-10 09:00:00 -0700"
415 | },
416 | {
417 | :start=>"2024-06-12 10:45:00 -0700",
418 | :end=>"2024-06-12 12:00:00 -0700"
419 | },
420 | {
421 | :start=>"2024-06-12 14:00:00 -0700",
422 | :end=>"2024-06-12 16:00:00 -0700"
423 | }
424 | ]
425 | )
426 | end
427 | end
428 | end
429 |
430 | context 'when exclusion_dates is provided' do
431 | subject do
432 | Periodoxical.generate(
433 | time_zone: 'America/Los_Angeles',
434 | starting_from: '2024-06-03',
435 | limit: 4,
436 | exclusion_dates: %w(2024-06-10),
437 | day_of_week_time_blocks: {
438 | mon: [
439 | { start_time: '8:00AM', end_time: '9:00AM' },
440 | ],
441 | }
442 | )
443 | end
444 |
445 | it 'returns the correct dates' do
446 | time_blocks = human_readable(subject)
447 | # All 8AM - 9AM Monday except the Monday of June 10, 2024
448 | expect(time_blocks).to eq(
449 | [
450 | {:start=>"2024-06-03 08:00:00 -0700", :end=>"2024-06-03 09:00:00 -0700"},
451 | {:start=>"2024-06-17 08:00:00 -0700", :end=>"2024-06-17 09:00:00 -0700"},
452 | {:start=>"2024-06-24 08:00:00 -0700", :end=>"2024-06-24 09:00:00 -0700"},
453 | {:start=>"2024-07-01 08:00:00 -0700", :end=>"2024-07-01 09:00:00 -0700"}
454 | ]
455 | )
456 | end
457 | end
458 |
459 | context 'when exclusion_times is provided' do
460 | subject do
461 | Periodoxical.generate(
462 | time_zone: 'America/Los_Angeles',
463 | starting_from: '2024-06-3',
464 | limit: 6,
465 | days_of_week: %w[mon],
466 | time_blocks: [
467 | { start_time: '8:00AM', end_time: '9:00AM' },
468 | { start_time: '10:00AM', end_time: '11:00AM' }
469 | ],
470 | exclusion_times: [
471 | {
472 | start: '2024-06-10T10:30:00-07:00',
473 | end: '2024-06-10T11:30:00-07:00'
474 | }
475 | ]
476 | )
477 | end
478 |
479 | it 'generates correct timeblocks' do
480 | time_blocks = human_readable(subject)
481 | expect(time_blocks).to eq(
482 | [
483 | {
484 | :start=>"2024-06-03 08:00:00 -0700",
485 | :end=>"2024-06-03 09:00:00 -0700"
486 | },
487 | {
488 | :start=>"2024-06-03 10:00:00 -0700",
489 | :end=>"2024-06-03 11:00:00 -0700"
490 | },
491 | {
492 | :start=>"2024-06-10 08:00:00 -0700",
493 | :end=>"2024-06-10 09:00:00 -0700"
494 | },
495 | {
496 | :start=>"2024-06-17 08:00:00 -0700",
497 | :end=>"2024-06-17 09:00:00 -0700"
498 | },
499 | {
500 | :start=>"2024-06-17 10:00:00 -0700",
501 | :end=>"2024-06-17 11:00:00 -0700"
502 | },
503 | {
504 | :start=>"2024-06-24 08:00:00 -0700",
505 | :end=>"2024-06-24 09:00:00 -0700"
506 | }
507 | ]
508 | )
509 | end
510 | end
511 |
512 | context 'when days_of_month is provided' do
513 | subject do
514 | Periodoxical.generate(
515 | time_zone: 'America/Los_Angeles',
516 | starting_from: '2024-06-3',
517 | limit: 4,
518 | days_of_month: [5, 10],
519 | time_blocks: [
520 | { start_time: '8:00AM', end_time: '9:00AM' }
521 | ],
522 | )
523 | end
524 |
525 | it 'generates the correct days' do
526 | time_blocks = human_readable(subject)
527 | expect(time_blocks).to eq(
528 | [
529 | {
530 | :start=>"2024-06-05 08:00:00 -0700",
531 | :end=>"2024-06-05 09:00:00 -0700"
532 | },
533 | {
534 | :start=>"2024-06-10 08:00:00 -0700",
535 | :end=>"2024-06-10 09:00:00 -0700"
536 | },
537 | {
538 | :start=>"2024-07-05 08:00:00 -0700",
539 | :end=>"2024-07-05 09:00:00 -0700"
540 | },
541 | {
542 | :start=>"2024-07-10 08:00:00 -0700",
543 | :end=>"2024-07-10 09:00:00 -0700"
544 | }
545 | ]
546 | )
547 | end
548 | end
549 |
550 | context 'when nth_day_of_week_in_month is provided' do
551 | subject do
552 | Periodoxical.generate(
553 | time_zone: 'America/Los_Angeles',
554 | starting_from: '2024-06-01',
555 | limit: 5,
556 | nth_day_of_week_in_month: {
557 | mon: [1, 2],
558 | fri: [-1]
559 | },
560 | time_blocks: [
561 | { start_time: '8:00AM', end_time: '9:00AM' },
562 | ],
563 | )
564 | end
565 |
566 | it 'generates the right time blocks' do
567 | time_blocks = human_readable(subject)
568 | expect(time_blocks).to eq(
569 | [
570 | {:start=>"2024-06-03 08:00:00 -0700", :end=>"2024-06-03 09:00:00 -0700"},
571 | {:start=>"2024-06-10 08:00:00 -0700", :end=>"2024-06-10 09:00:00 -0700"},
572 | {:start=>"2024-06-28 08:00:00 -0700", :end=>"2024-06-28 09:00:00 -0700"},
573 | {:start=>"2024-07-01 08:00:00 -0700", :end=>"2024-07-01 09:00:00 -0700"},
574 | {:start=>"2024-07-08 08:00:00 -0700", :end=>"2024-07-08 09:00:00 -0700"}
575 | ]
576 | )
577 | end
578 | end
579 |
580 | context 'when alternating days of the week' do
581 | subject do
582 | Periodoxical.generate(
583 | time_zone: 'America/Los_Angeles',
584 | starting_from: '2024-12-30',
585 | days_of_week: {
586 | mon: { every: true }, # every Monday
587 | tue: { every_other_nth: 2 }, # every other Tuesday
588 | wed: { every_other_nth: 3 }, # every 3rd Wednesday
589 | },
590 | limit: 10,
591 | time_blocks: [
592 | { start_time: '9:00AM', end_time: '10:00AM' },
593 | ],
594 | )
595 | end
596 |
597 | it 'generates the correct timeblocks' do
598 | time_blocks = human_readable(subject)
599 |
600 | expect(time_blocks).to eq(
601 | [
602 | {
603 | :start=>"2024-12-30 09:00:00 -0800",
604 | :end=>"2024-12-30 10:00:00 -0800"
605 | },
606 | {
607 | :start=>"2024-12-31 09:00:00 -0800",
608 | :end=>"2024-12-31 10:00:00 -0800"
609 | },
610 | {
611 | :start=>"2025-01-01 09:00:00 -0800",
612 | :end=>"2025-01-01 10:00:00 -0800"
613 | },
614 | {
615 | :start=>"2025-01-06 09:00:00 -0800",
616 | :end=>"2025-01-06 10:00:00 -0800"
617 | },
618 | {
619 | :start=>"2025-01-13 09:00:00 -0800",
620 | :end=>"2025-01-13 10:00:00 -0800"
621 | },
622 | {
623 | :start=>"2025-01-14 09:00:00 -0800",
624 | :end=>"2025-01-14 10:00:00 -0800"
625 | },
626 | {
627 | :start=>"2025-01-20 09:00:00 -0800",
628 | :end=>"2025-01-20 10:00:00 -0800"
629 | },
630 | {
631 | :start=>"2025-01-22 09:00:00 -0800",
632 | :end=>"2025-01-22 10:00:00 -0800"
633 | },
634 | {
635 | :start=>"2025-01-27 09:00:00 -0800",
636 | :end=>"2025-01-27 10:00:00 -0800"
637 | },
638 | {
639 | :start=>"2025-01-28 09:00:00 -0800",
640 | :end=>"2025-01-28 10:00:00 -0800"
641 | }
642 | ]
643 | )
644 | end
645 | end
646 |
647 | context 'when duration key is provided' do
648 | subject do
649 | Periodoxical.generate(
650 | time_zone: 'America/Los_Angeles',
651 | time_blocks: [
652 | {
653 | start_time: '9:00AM',
654 | end_time: '1:00PM'
655 | },
656 | {
657 | start_time: '2:00PM',
658 | end_time: '5:00PM'
659 | },
660 | ],
661 | duration: 30, # minutes
662 | starting_from: '2024-05-23',
663 | ending_at: '2024-05-24',
664 | )
665 | end
666 |
667 | it 'splits based on durations' do
668 | time_blocks = human_readable(subject)
669 |
670 | expect(time_blocks).to eq(
671 | [
672 | {:start=>"2024-05-23 09:00:00 -0700", :end=>"2024-05-23 09:30:00 -0700"},
673 | {:start=>"2024-05-23 09:30:00 -0700", :end=>"2024-05-23 10:00:00 -0700"},
674 | {:start=>"2024-05-23 10:00:00 -0700", :end=>"2024-05-23 10:30:00 -0700"},
675 | {:start=>"2024-05-23 10:30:00 -0700", :end=>"2024-05-23 11:00:00 -0700"},
676 | {:start=>"2024-05-23 11:00:00 -0700", :end=>"2024-05-23 11:30:00 -0700"},
677 | {:start=>"2024-05-23 11:30:00 -0700", :end=>"2024-05-23 12:00:00 -0700"},
678 | {:start=>"2024-05-23 12:00:00 -0700", :end=>"2024-05-23 12:30:00 -0700"},
679 | {:start=>"2024-05-23 12:30:00 -0700", :end=>"2024-05-23 13:00:00 -0700"},
680 | {:start=>"2024-05-23 14:00:00 -0700", :end=>"2024-05-23 14:30:00 -0700"},
681 | {:start=>"2024-05-23 14:30:00 -0700", :end=>"2024-05-23 15:00:00 -0700"},
682 | {:start=>"2024-05-23 15:00:00 -0700", :end=>"2024-05-23 15:30:00 -0700"},
683 | {:start=>"2024-05-23 15:30:00 -0700", :end=>"2024-05-23 16:00:00 -0700"},
684 | {:start=>"2024-05-23 16:00:00 -0700", :end=>"2024-05-23 16:30:00 -0700"},
685 | {:start=>"2024-05-23 16:30:00 -0700", :end=>"2024-05-23 17:00:00 -0700"},
686 | {:start=>"2024-05-24 09:00:00 -0700", :end=>"2024-05-24 09:30:00 -0700"},
687 | {:start=>"2024-05-24 09:30:00 -0700", :end=>"2024-05-24 10:00:00 -0700"},
688 | {:start=>"2024-05-24 10:00:00 -0700", :end=>"2024-05-24 10:30:00 -0700"},
689 | {:start=>"2024-05-24 10:30:00 -0700", :end=>"2024-05-24 11:00:00 -0700"},
690 | {:start=>"2024-05-24 11:00:00 -0700", :end=>"2024-05-24 11:30:00 -0700"},
691 | {:start=>"2024-05-24 11:30:00 -0700", :end=>"2024-05-24 12:00:00 -0700"},
692 | {:start=>"2024-05-24 12:00:00 -0700", :end=>"2024-05-24 12:30:00 -0700"},
693 | {:start=>"2024-05-24 12:30:00 -0700", :end=>"2024-05-24 13:00:00 -0700"},
694 | {:start=>"2024-05-24 14:00:00 -0700", :end=>"2024-05-24 14:30:00 -0700"},
695 | {:start=>"2024-05-24 14:30:00 -0700", :end=>"2024-05-24 15:00:00 -0700"},
696 | {:start=>"2024-05-24 15:00:00 -0700", :end=>"2024-05-24 15:30:00 -0700"},
697 | {:start=>"2024-05-24 15:30:00 -0700", :end=>"2024-05-24 16:00:00 -0700"},
698 | {:start=>"2024-05-24 16:00:00 -0700", :end=>"2024-05-24 16:30:00 -0700"},
699 | {:start=>"2024-05-24 16:30:00 -0700", :end=>"2024-05-24 17:00:00 -0700"}]
700 | )
701 | end
702 |
703 | context 'when exclusion_times is provided' do
704 | subject do
705 | Periodoxical.generate(
706 | time_zone: 'America/Los_Angeles',
707 | time_blocks: [
708 | {
709 | start_time: '9:00AM',
710 | end_time: '1:00PM'
711 | },
712 | {
713 | start_time: '2:00PM',
714 | end_time: '5:00PM'
715 | },
716 | ],
717 | duration: 30, # minutes
718 | starting_from: '2024-05-23',
719 | ending_at: '2024-05-24',
720 | exclusion_times: [
721 | {
722 | start: '2024-05-23T10:30:00-07:00',
723 | end: '2024-05-23T11:00:00-07:00'
724 | }
725 | ]
726 | )
727 | end
728 |
729 | it 'works well with exclusion_times' do
730 | time_blocks = human_readable(subject)
731 |
732 | expect(time_blocks).to eq(
733 | [
734 | { :start=>"2024-05-23 09:00:00 -0700", :end=>"2024-05-23 09:30:00 -0700" },
735 | { :start=>"2024-05-23 09:30:00 -0700", :end=>"2024-05-23 10:00:00 -0700" },
736 | { :start=>"2024-05-23 10:00:00 -0700", :end=>"2024-05-23 10:30:00 -0700" },
737 | # 10:30AM - 11:00AM on 5/23 was excluded
738 | { :start=>"2024-05-23 11:00:00 -0700", :end=>"2024-05-23 11:30:00 -0700" },
739 | { :start=>"2024-05-23 11:30:00 -0700", :end=>"2024-05-23 12:00:00 -0700" },
740 | { :start=>"2024-05-23 12:00:00 -0700", :end=>"2024-05-23 12:30:00 -0700" },
741 | { :start=>"2024-05-23 12:30:00 -0700", :end=>"2024-05-23 13:00:00 -0700" },
742 | { :start=>"2024-05-23 14:00:00 -0700", :end=>"2024-05-23 14:30:00 -0700" },
743 | { :start=>"2024-05-23 14:30:00 -0700", :end=>"2024-05-23 15:00:00 -0700" },
744 | { :start=>"2024-05-23 15:00:00 -0700", :end=>"2024-05-23 15:30:00 -0700" },
745 | { :start=>"2024-05-23 15:30:00 -0700", :end=>"2024-05-23 16:00:00 -0700" },
746 | { :start=>"2024-05-23 16:00:00 -0700", :end=>"2024-05-23 16:30:00 -0700" },
747 | { :start=>"2024-05-23 16:30:00 -0700", :end=>"2024-05-23 17:00:00 -0700" },
748 | { :start=>"2024-05-24 09:00:00 -0700", :end=>"2024-05-24 09:30:00 -0700" },
749 | { :start=>"2024-05-24 09:30:00 -0700", :end=>"2024-05-24 10:00:00 -0700" },
750 | { :start=>"2024-05-24 10:00:00 -0700", :end=>"2024-05-24 10:30:00 -0700" },
751 | { :start=>"2024-05-24 10:30:00 -0700", :end=>"2024-05-24 11:00:00 -0700" },
752 | { :start=>"2024-05-24 11:00:00 -0700", :end=>"2024-05-24 11:30:00 -0700" },
753 | { :start=>"2024-05-24 11:30:00 -0700", :end=>"2024-05-24 12:00:00 -0700" },
754 | { :start=>"2024-05-24 12:00:00 -0700", :end=>"2024-05-24 12:30:00 -0700" },
755 | { :start=>"2024-05-24 12:30:00 -0700", :end=>"2024-05-24 13:00:00 -0700" },
756 | { :start=>"2024-05-24 14:00:00 -0700", :end=>"2024-05-24 14:30:00 -0700" },
757 | { :start=>"2024-05-24 14:30:00 -0700", :end=>"2024-05-24 15:00:00 -0700" },
758 | { :start=>"2024-05-24 15:00:00 -0700", :end=>"2024-05-24 15:30:00 -0700" },
759 | { :start=>"2024-05-24 15:30:00 -0700", :end=>"2024-05-24 16:00:00 -0700" },
760 | { :start=>"2024-05-24 16:00:00 -0700", :end=>"2024-05-24 16:30:00 -0700" },
761 | { :start=>"2024-05-24 16:30:00 -0700", :end=>"2024-05-24 17:00:00 -0700" }
762 | ]
763 | )
764 | end
765 | end
766 | end
767 |
768 | context 'Friday the 13ths' do
769 | subject do
770 | Periodoxical.generate(
771 | time_zone: 'America/Los_Angeles',
772 | starting_from: '1980-05-01',
773 | days_of_week: %w(fri),
774 | days_of_month: [13],
775 | limit: 10,
776 | time_blocks: [
777 | { start_time: '11:00PM', end_time: '12:00AM' },
778 | ],
779 | )
780 | end
781 |
782 | it 'generates the correct time slots' do
783 | time_blocks = human_readable(subject)
784 | expect(time_blocks).to eq(
785 | [
786 | {:start=>"1980-06-13 23:00:00 -0700",
787 | :end=>"1980-06-13 00:00:00 -0700"},
788 | {:start=>"1981-02-13 23:00:00 -0800",
789 | :end=>"1981-02-13 00:00:00 -0800"},
790 | {:start=>"1981-03-13 23:00:00 -0800",
791 | :end=>"1981-03-13 00:00:00 -0800"},
792 | {:start=>"1981-11-13 23:00:00 -0800",
793 | :end=>"1981-11-13 00:00:00 -0800"},
794 | {:start=>"1982-08-13 23:00:00 -0700",
795 | :end=>"1982-08-13 00:00:00 -0700"},
796 | {:start=>"1983-05-13 23:00:00 -0700",
797 | :end=>"1983-05-13 00:00:00 -0700"},
798 | {:start=>"1984-01-13 23:00:00 -0800",
799 | :end=>"1984-01-13 00:00:00 -0800"},
800 | {:start=>"1984-04-13 23:00:00 -0800",
801 | :end=>"1984-04-13 00:00:00 -0800"},
802 | {:start=>"1984-07-13 23:00:00 -0700",
803 | :end=>"1984-07-13 00:00:00 -0700"},
804 | {:start=>"1985-09-13 23:00:00 -0700",
805 | :end=>"1985-09-13 00:00:00 -0700"}
806 | ]
807 | )
808 | end
809 | end
810 |
811 | context 'Thanksgivings' do
812 | subject do
813 | Periodoxical.generate(
814 | time_zone: 'America/Los_Angeles',
815 | starting_from: '2024-05-01',
816 | months: [11],
817 | nth_day_of_week_in_month: {
818 | thu: [4],
819 | },
820 | limit: 10,
821 | time_blocks: [
822 | { start_time: '5:00PM', end_time: '6:00PM' },
823 | ],
824 | )
825 | end
826 |
827 | it 'generates the correct time slots' do
828 | time_blocks = human_readable(subject)
829 |
830 | expect(time_blocks).to eq(
831 | [
832 | {:start=>"2024-11-28 17:00:00 -0800", :end=>"2024-11-28 18:00:00 -0800"},
833 | {:start=>"2025-11-27 17:00:00 -0800", :end=>"2025-11-27 18:00:00 -0800"},
834 | {:start=>"2026-11-26 17:00:00 -0800", :end=>"2026-11-26 18:00:00 -0800"},
835 | {:start=>"2027-11-25 17:00:00 -0800", :end=>"2027-11-25 18:00:00 -0800"},
836 | {:start=>"2028-11-23 17:00:00 -0800", :end=>"2028-11-23 18:00:00 -0800"},
837 | {:start=>"2029-11-22 17:00:00 -0800", :end=>"2029-11-22 18:00:00 -0800"},
838 | {:start=>"2030-11-28 17:00:00 -0800", :end=>"2030-11-28 18:00:00 -0800"},
839 | {:start=>"2031-11-27 17:00:00 -0800", :end=>"2031-11-27 18:00:00 -0800"},
840 | {:start=>"2032-11-25 17:00:00 -0800", :end=>"2032-11-25 18:00:00 -0800"},
841 | {:start=>"2033-11-24 17:00:00 -0800", :end=>"2033-11-24 18:00:00 -0800"}]
842 | )
843 | end
844 | end
845 | end
846 |
847 | describe '#overlap?' do
848 | subject do
849 | Periodoxical::Core.new(
850 | starting_from: '2024-06-04', time_blocks: [], limit: 4
851 | ).send(:overlap?, time_block_1, time_block_2)
852 | end
853 |
854 | context 'when time_block_1 is before time_block_2 with no overlap' do
855 | # 9-10 11-12
856 | let(:time_block_1) do
857 | {
858 | start: DateTime.parse("2024-06-03T9:00:00"),
859 | end: DateTime.parse("2024-06-03T10:00:00")
860 | }
861 | end
862 |
863 | let(:time_block_2) do
864 | {
865 | start: DateTime.parse("2024-06-03T11:00:00"),
866 | end: DateTime.parse("2024-06-03T12:00:00")
867 | }
868 | end
869 |
870 | it 'returns false' do
871 | expect(subject).to eq(false)
872 | end
873 | end
874 |
875 | context 'when time_block_1 is after time_block_2 with no overlap' do
876 | # 11-12 vs 9-10
877 | let(:time_block_1) do
878 | {
879 | start: DateTime.parse("2024-06-03T11:00:00"),
880 | end: DateTime.parse("2024-06-03T12:00:00")
881 | }
882 | end
883 |
884 | let(:time_block_2) do
885 | {
886 | start: DateTime.parse("2024-06-03T9:00:00"),
887 | end: DateTime.parse("2024-06-03T10:00:00")
888 | }
889 | end
890 |
891 | it 'returns false' do
892 | expect(subject).to eq(false)
893 | end
894 | end
895 |
896 | context 'when time_block_1 overlaps with time_block_2 case 1 ' do
897 | # 9-11 vs 10-12
898 | let(:time_block_1) do
899 | {
900 | start: DateTime.parse("2024-06-03T9:00:00"),
901 | end: DateTime.parse("2024-06-03T11:00:00")
902 | }
903 | end
904 |
905 | let(:time_block_2) do
906 | {
907 | start: DateTime.parse("2024-06-03T10:00:00"),
908 | end: DateTime.parse("2024-06-03T12:00:00")
909 | }
910 | end
911 |
912 | it 'returns true' do
913 | expect(subject).to eq(true)
914 | end
915 | end
916 |
917 | context 'when time_block_1 overlaps with time_block_2 case 2 ' do
918 | # 9-11 vs 10-12
919 | let(:time_block_2) do
920 | {
921 | start: DateTime.parse("2024-06-03T9:00:00"),
922 | end: DateTime.parse("2024-06-03T11:00:00")
923 | }
924 | end
925 |
926 | let(:time_block_1) do
927 | {
928 | start: DateTime.parse("2024-06-03T10:00:00"),
929 | end: DateTime.parse("2024-06-03T12:00:00")
930 | }
931 | end
932 |
933 | it 'returns true' do
934 | expect(subject).to eq(true)
935 | end
936 | end
937 |
938 | context 'when one is contained entirely within the other' do
939 | # 10-11 vs 9-12
940 | let(:time_block_1) do
941 | {
942 | start: DateTime.parse("2024-06-03T10:00:00"),
943 | end: DateTime.parse("2024-06-03T11:00:00")
944 | }
945 | end
946 |
947 | let(:time_block_2) do
948 | {
949 | start: DateTime.parse("2024-06-03T9:00:00"),
950 | end: DateTime.parse("2024-06-03T12:00:00")
951 | }
952 | end
953 |
954 | it 'returns true' do
955 | expect(subject).to eq(true)
956 | end
957 | end
958 |
959 | context 'when time blocks are the same' do
960 | let(:time_block_1) do
961 | {
962 | start: DateTime.parse("2024-06-03T10:00:00"),
963 | end: DateTime.parse("2024-06-03T11:00:00")
964 | }
965 | end
966 |
967 | let(:time_block_2) do
968 | {
969 | start: DateTime.parse("2024-06-03T10:00:00"),
970 | end: DateTime.parse("2024-06-03T11:00:00")
971 | }
972 | end
973 |
974 | it 'returns true' do
975 | expect(subject).to eq(true)
976 | end
977 | end
978 |
979 | context 'when time blocks start at the same time' do
980 | # 10-11 vs 10-12
981 | let(:time_block_1) do
982 | {
983 | start: DateTime.parse("2024-06-03T10:00:00"),
984 | end: DateTime.parse("2024-06-03T11:00:00")
985 | }
986 | end
987 |
988 | let(:time_block_2) do
989 | {
990 | start: DateTime.parse("2024-06-03T10:00:00"),
991 | end: DateTime.parse("2024-06-03T12:00:00")
992 | }
993 | end
994 |
995 | it 'returns true' do
996 | expect(subject).to eq(true)
997 | end
998 | end
999 |
1000 | context 'when time blocks end at the same time' do
1001 | # 10-12 vs 11-12
1002 | let(:time_block_1) do
1003 | {
1004 | start: DateTime.parse("2024-06-03T10:00:00"),
1005 | end: DateTime.parse("2024-06-03T12:00:00")
1006 | }
1007 | end
1008 |
1009 | let(:time_block_2) do
1010 | {
1011 | start: DateTime.parse("2024-06-03T11:00:00"),
1012 | end: DateTime.parse("2024-06-03T12:00:00")
1013 | }
1014 | end
1015 |
1016 | it 'returns true' do
1017 | expect(subject).to eq(true)
1018 | end
1019 | end
1020 |
1021 | context 'when time blocks are adjacent and touching' do
1022 | let(:time_block_1) do
1023 | {
1024 | start: DateTime.parse("2024-06-03T10:00:00"),
1025 | end: DateTime.parse("2024-06-03T11:00:00")
1026 | }
1027 | end
1028 |
1029 | let(:time_block_2) do
1030 | {
1031 | start: DateTime.parse("2024-06-03T11:00:00"),
1032 | end: DateTime.parse("2024-06-03T12:00:00")
1033 | }
1034 | end
1035 |
1036 | it 'returns false' do
1037 | expect(subject).to eq(false)
1038 | end
1039 | end
1040 | end
1041 | end
1042 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require "bundler/setup"
2 | require "periodoxical"
3 |
4 | RSpec.configure do |config|
5 | # Enable flags like --only-failures and --next-failure
6 | config.example_status_persistence_file_path = ".rspec_status"
7 |
8 | # Disable RSpec exposing methods globally on `Module` and `main`
9 | config.disable_monkey_patching!
10 |
11 | config.expect_with :rspec do |c|
12 | c.syntax = :expect
13 | end
14 | end
15 |
--------------------------------------------------------------------------------