├── .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 | pink_floyd_time 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 | calendar_image_1 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 | calendar_image_2 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 | alt_google_cal_image 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 | --------------------------------------------------------------------------------