enumdate 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c7941bd64bd2ffee84449e892558f43592866010fcd92d8809be7df94ec80f3a
4
+ data.tar.gz: f9ae67f04f0e5134700c4ad535d69f94f7e685607bb3fa8d9e69946c6610104b
5
+ SHA512:
6
+ metadata.gz: 0a2c4d506c93db39dd5e56ab5a562294d747e47977133191fe9682879dd1cfafb146214812a698da2837443e0a0aa32805e25c1991ebb0881b825eb596fc5929
7
+ data.tar.gz: eb6686a639cd867b90a938425abdb90f2465b2dc1fdefe79fb3d20a9ca1e66f477056a3a0b85d469e1e12794d94eec4771e63fcd781cb6e312d6169fd8cb8648
@@ -0,0 +1,16 @@
1
+ name: Ruby
2
+
3
+ on: [push,pull_request]
4
+
5
+ jobs:
6
+ build:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v2
10
+ - name: Set up Ruby
11
+ uses: ruby/setup-ruby@v1
12
+ with:
13
+ ruby-version: 3.0.2
14
+ bundler-cache: true
15
+ - name: Run the default task
16
+ run: bundle exec rake
data/.gitignore ADDED
@@ -0,0 +1,65 @@
1
+ ################################################################
2
+ # Added from https://raw.github.com/github/gitignore/master/Ruby.gitignore
3
+ #
4
+
5
+ *.gem
6
+ *.rbc
7
+ /.config
8
+ /coverage/
9
+ /InstalledFiles
10
+ /pkg/
11
+ /spec/reports/
12
+ /spec/examples.txt
13
+ /test/tmp/
14
+ /test/version_tmp/
15
+ /tmp/
16
+
17
+ # Used by dotenv library to load environment variables.
18
+ # .env
19
+
20
+ # Ignore Byebug command history file.
21
+ .byebug_history
22
+
23
+ ## Specific to RubyMotion:
24
+ .dat*
25
+ .repl_history
26
+ build/
27
+ *.bridgesupport
28
+ build-iPhoneOS/
29
+ build-iPhoneSimulator/
30
+
31
+ ## Specific to RubyMotion (use of CocoaPods):
32
+ #
33
+ # We recommend against adding the Pods directory to your .gitignore. However
34
+ # you should judge for yourself, the pros and cons are mentioned at:
35
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
36
+ #
37
+ # vendor/Pods/
38
+
39
+ ## Documentation cache and generated files:
40
+ /.yardoc/
41
+ /_yardoc/
42
+ /doc/
43
+ /rdoc/
44
+
45
+ ## Environment normalization:
46
+ /.bundle/
47
+ /vendor/bundle
48
+ /lib/bundler/man/
49
+
50
+ # for a library or gem, you might want to ignore these files since the code is
51
+ # intended to run in multiple environments; otherwise, check them in:
52
+ Gemfile.lock
53
+ .ruby-version
54
+ .ruby-gemset
55
+
56
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
57
+ .rvmrc
58
+
59
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
60
+ .rubocop-https?--*
61
+
62
+ ################################################################
63
+ # Site-locals
64
+
65
+ attic/
data/.rubocop.yml ADDED
@@ -0,0 +1,32 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.5
3
+ NewCops: enable
4
+
5
+ Layout/LineLength:
6
+ Max: 120
7
+
8
+ Layout/MultilineMethodCallIndentation:
9
+ EnforcedStyle: indented_relative_to_receiver
10
+
11
+ Layout/SpaceInsideBlockBraces:
12
+ SpaceBeforeBlockParameters: false
13
+
14
+ Metrics/AbcSize:
15
+ Max: 25
16
+
17
+ Metrics/ParameterLists:
18
+ CountKeywordArgs: false
19
+
20
+ Style/Alias:
21
+ Enabled: true
22
+ EnforcedStyle: prefer_alias_method
23
+
24
+ Style/FormatStringToken:
25
+ Enabled: false
26
+
27
+ Style/ParallelAssignment:
28
+ Enabled: false
29
+
30
+ Style/StringLiterals:
31
+ Enabled: true
32
+ EnforcedStyle: double_quotes
data/.solargraph.yml ADDED
@@ -0,0 +1,21 @@
1
+ ---
2
+ include:
3
+ - "**/*.rb"
4
+ exclude:
5
+ - spec/**/*
6
+ - test/**/*
7
+ - vendor/**/*
8
+ - ".bundle/**/*"
9
+ require: []
10
+ domains: []
11
+ reporters:
12
+ - rubocop
13
+ formatter:
14
+ rubocop:
15
+ cops: safe
16
+ except: []
17
+ only: []
18
+ extra_args: []
19
+ require_paths: []
20
+ plugins: []
21
+ max_files: 5000
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in enumdate.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "minitest", "~> 5.0"
11
+
12
+ gem "rubocop", "~> 1.7"
13
+
14
+ gem "solargraph"
15
+
16
+ gem "org-ruby"
17
+ gem "yard"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Yoshinari Nomura
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.
data/README.org ADDED
@@ -0,0 +1,147 @@
1
+ #+TITLE: enumdate -- a small enumerator library to expand recurring dates
2
+ #+AUTHOR: Yoshinari Nomura
3
+ #+EMAIL:
4
+ #+DATE: 2021-08-21
5
+ #+OPTIONS: H:3 num:2 toc:nil
6
+ #+OPTIONS: ^:nil @:t \n:nil ::t |:t f:t TeX:t
7
+ #+OPTIONS: skip:nil
8
+ #+OPTIONS: author:t
9
+ #+OPTIONS: email:nil
10
+ #+OPTIONS: creator:nil
11
+ #+OPTIONS: timestamp:nil
12
+ #+OPTIONS: timestamps:nil
13
+ #+OPTIONS: d:nil
14
+ #+OPTIONS: tags:t
15
+ #+LANGUAGE: ja
16
+
17
+ [[https://badge.fury.io/rb/enumdate.svg]]
18
+ [[https://github.com/yoshinari-nomura/enumdate/actions/workflows/main.yml/badge.svg]]
19
+
20
+ ** Description
21
+ Enumdate is a small enumerator library to expand recurring dates.
22
+
23
+ You can get the latest version from:
24
+ + https://github.com/yoshinari-nomura/enumdate
25
+
26
+ ** How to use
27
+ *** Create enumerables
28
+ Thease a simple example to create enumerables:
29
+ #+begin_src ruby
30
+ # June 2021
31
+ # Su Mo Tu We Th Fr Sa
32
+ # 1 2 3 4 5
33
+ # 6 7 8 9 10 11 12
34
+ # 13 14 15 16 17 18 19
35
+ # 20 21 22 23 24 25 26
36
+ # 27 28 29 30
37
+ #
38
+ start = Date.new(2021, 6, 1) # first Tuesday June
39
+
40
+ # June 1st every year:
41
+ Enumdate.yearly_by_monthday(start).lazy.map(&:to_s).take(3).force
42
+ # => ["2021-06-01", "2022-06-01", "2023-06-01"]
43
+
44
+ # First Tuesday June every year:
45
+ Enumdate.yearly_by_day(start).lazy.map(&:to_s).take(3).force
46
+ # => ["2021-06-01", "2022-06-07", "2023-06-06"]
47
+
48
+ # 1st monthday every month:
49
+ Enumdate.monthly_by_monthday(start).lazy.map(&:to_s).take(3).force
50
+ # => ["2021-06-01", "2021-07-01", "2021-08-01"]
51
+
52
+ # First Tuesday every month:
53
+ Enumdate.monthly_by_day(start).lazy.map(&:to_s).take(3).force
54
+ # => ["2021-06-01", "2021-07-06", "2021-08-03"]
55
+
56
+ # every Tuesday:
57
+ Enumdate.weekly(start).lazy.map(&:to_s).take(3).force
58
+ # => ["2021-06-01", "2021-06-08", "2021-06-15"]
59
+
60
+ # Everyday:
61
+ Enumdate.daily(start).lazy.map(&:to_s).take(3).force
62
+ # => ["2021-06-01", "2021-06-02", "2021-06-03"]
63
+ #+end_src
64
+
65
+ These constructor methods can take more complex parameters
66
+ such as ~month:~, ~mday:~, ~wday:~, ~nth:~, ~wkst:~, ~interval:~.
67
+ See the gem document for details.
68
+
69
+ *** Make finit durations
70
+ This code makes infinit loop (every two years forever):
71
+ #+begin_src ruby
72
+ start = Date.new(2021, 6, 1)
73
+ Enumdate.yearly_by_monthday(start, interval: 2).each do |date|
74
+ puts date
75
+ end
76
+ #+end_src
77
+ Results:
78
+ : 2021-06-01
79
+ : 2023-06-01
80
+ : 2025-06-01
81
+ : :
82
+
83
+ To clip the duration, you can use ~forward_to~ and ~until~:
84
+ #+begin_src ruby
85
+ start = Date.new(2021, 6, 1)
86
+ Enumdate.yearly_by_monthday(start, interval: 2)
87
+ .forward_to(Date.new(2022, 1, 1))
88
+ .until(Date.new(2025, 12, 31))
89
+ .each do |date|
90
+ puts date
91
+ end
92
+ #+end_src
93
+ Rssults:
94
+ : 2023-06-01
95
+ : 2025-06-01
96
+
97
+ Note that the meaning of ~forward_to~ is different from that of
98
+ changing the ~start~ parameter.
99
+ #+begin_src ruby
100
+ start = Date.new(2022, 1, 1) # changed as if set forward_to
101
+ Enumdate.yearly_by_monthday(start, interval: 2)
102
+ .until(Date.new(2025, 12, 31))
103
+ .each do |date|
104
+ puts date
105
+ end
106
+ #+end_src
107
+ Rssults:
108
+ : 2022-01-01
109
+ : 2024-01-01
110
+
111
+ ~forward_to~ and ~until~ clip concrete occurrences without changing
112
+ the recurring pattern.
113
+
114
+ *** Merge multiple enumerables
115
+ Sometimes, you may need to compose more complex recurring patterns.
116
+ In this case, you can merge multiple enumerables:
117
+ #+begin_src ruby
118
+ mon = Date.new(2021, 8, 2) # = 1st Monday
119
+ wed = Date.new(2021, 8, 4) # = 1st Wednesday
120
+
121
+ # Every Monday and Wednesday:
122
+ (Enumdate::EnumMerger.new << Enumdate.weekly(mon) << Enumdate.weekly(wed))
123
+ .lazy.map(&:to_s).take(4).force
124
+ # => ["2021-08-02", "2021-08-04", "2021-08-09", "2021-08-11"]
125
+ #+end_src
126
+
127
+ ** Installation
128
+ Add this line to your application's Gemfile:
129
+ #+begin_src ruby
130
+ gem "enumdate"
131
+ #+end_src
132
+
133
+ And then execute:
134
+ #+begin_src shell-script
135
+ $ bundle install
136
+ #+end_src
137
+
138
+ Or install it yourself as:
139
+ #+begin_src shell-script
140
+ $ gem install enumdate
141
+ #+end_src
142
+
143
+ ** Contributing
144
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yoshinari-nomura/enumdate.
145
+
146
+ ** License
147
+ The gem is available as open source under the terms of the [[https://opensource.org/licenses/MIT][MIT License]].
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "rubocop/rake_task"
6
+ require "yard"
7
+
8
+ Rake::TestTask.new(:test) do |t|
9
+ t.libs << "test"
10
+ t.libs << "lib"
11
+ t.test_files = FileList["test/**/*_test.rb"]
12
+ end
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ YARD::Rake::YardocTask.new do |t|
17
+ t.options = ["-m", "org"]
18
+ end
19
+
20
+ task doc: %i[yard]
21
+ task default: %i[test rubocop]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "enumdate"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
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
data/enumdate.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/enumdate/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "enumdate"
7
+ spec.version = Enumdate::VERSION
8
+ spec.authors = ["Yoshinari Nomura"]
9
+ spec.email = ["nom@quickhack.net"]
10
+
11
+ spec.summary = "Enumerator for recurring dates."
12
+ spec.homepage = "https://github.com/yoshinari-nomura/enumdate"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 2.5.0"
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/yoshinari-nomura/enumdate"
18
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject {|f| f.match(%r{\A(?:test|spec|features)/}) }
24
+ end
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{\Aexe/}) {|f| File.basename(f) }
27
+ spec.require_paths = ["lib"]
28
+ end
data/lib/enumdate.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enumerator for recurring dates
4
+ module Enumdate
5
+ class Error < StandardError; end
6
+
7
+ require "date"
8
+
9
+ dir = File.expand_path("enumdate", File.dirname(__FILE__))
10
+
11
+ autoload :EnumMerger, "#{dir}/enum_merger.rb"
12
+ autoload :DateEnumerator, "#{dir}/date_enumerator.rb"
13
+ autoload :DateFrame, "#{dir}/date_frame.rb"
14
+ autoload :DateHelper, "#{dir}/date_helper.rb"
15
+ autoload :VERSION, "#{dir}/version.rb"
16
+
17
+ class << self
18
+ # @return [DateEnumerator::YearlyByMonthday]
19
+ def yearly_by_monthday(first_date, month: nil, mday: nil, interval: 1)
20
+ month ||= first_date.month
21
+ mday ||= first_date.mday
22
+ DateEnumerator::YearlyByMonthday.new(
23
+ first_date: first_date,
24
+ month: month,
25
+ mday: mday,
26
+ interval: interval
27
+ )
28
+ end
29
+
30
+ # @return [DateEnumerator::YearlyByDay]
31
+ def yearly_by_day(first_date, month: nil, nth: nil, wday: nil, interval: 1)
32
+ month ||= first_date.month
33
+ nth ||= (first_date.mday + 6) / 7
34
+ wday ||= first_date.wday
35
+ DateEnumerator::YearlyByDay.new(
36
+ first_date: first_date,
37
+ month: month,
38
+ nth: nth,
39
+ wday: wday,
40
+ interval: interval
41
+ )
42
+ end
43
+
44
+ # @return [DateEnumerator::MonthlyByMonthday]
45
+ def monthly_by_monthday(first_date, mday: nil, interval: 1)
46
+ mday ||= first_date.mday
47
+ DateEnumerator::MonthlyByMonthday.new(
48
+ first_date: first_date,
49
+ mday: mday,
50
+ interval: interval
51
+ )
52
+ end
53
+
54
+ # @return [DateEnumerator::MonthlyByDay]
55
+ def monthly_by_day(first_date, nth: nil, wday: nil, interval: 1)
56
+ nth ||= (first_date.mday + 6) / 7
57
+ wday ||= first_date.wday
58
+ DateEnumerator::MonthlyByDay.new(
59
+ first_date: first_date,
60
+ nth: nth,
61
+ wday: wday,
62
+ interval: interval
63
+ )
64
+ end
65
+
66
+ # @return [DateEnumerator::Weekly]
67
+ def weekly(first_date, wday: nil, wkst: 1, interval: 1)
68
+ wday ||= first_date.wday
69
+ DateEnumerator::Weekly.new(
70
+ first_date: first_date,
71
+ wday: wday,
72
+ wkst: wkst,
73
+ interval: interval
74
+ )
75
+ end
76
+
77
+ # @return [DateEnumerator::Daily]
78
+ def daily(first_date, interval: 1)
79
+ DateEnumerator::Daily.new(
80
+ first_date: first_date,
81
+ interval: interval
82
+ )
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ module DateEnumerator
5
+ # Base class for DateEnumerator
6
+ class Base
7
+ include DateHelper
8
+ include Enumerable
9
+
10
+ def initialize(first_date:, interval: 1, wkst: 1)
11
+ @first_date, @interval, @wkst = first_date, interval, wkst
12
+ @frame_manager = frame_manager.new(first_date, interval, wkst)
13
+ @duration_begin = first_date
14
+ @duration_until = nil
15
+ end
16
+
17
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
18
+ def each
19
+ return enum_for(:each) unless block_given?
20
+
21
+ # ~first_date~ value always counts as the first occurrence
22
+ # even if it does not match the specified rule.
23
+ # cf. DTSTART vs RRULE in RFC5445.
24
+ yield @first_date if between_duration?(@first_date)
25
+
26
+ @frame_manager.each do |frame|
27
+ # Avoid inifinit loops even if the rule emits no occurrences
28
+ # such as "31st April in every year".
29
+ # (Every ~occurrence_in_frame(frame)~ returns nil)
30
+ break if @duration_until && @duration_until < frame
31
+
32
+ # In some cases, ~occurrence_in_frame~ returns nil.
33
+ # For example, a recurrence that returns 31st of each month
34
+ # will return nil for short months such as April and June.
35
+ next unless (date = occurrence_in_frame(frame))
36
+
37
+ break if @duration_until && @duration_until < date
38
+
39
+ # ~occurrence_in_frame~ may return a date earlier than
40
+ # ~first_date~ in the first iteration. This is because
41
+ # ~first_date~ does not necessarily follow the repetetion
42
+ # rule. For example, if the rule is "every August 1st" and
43
+ # ~first_date~ is August 15th, The first occurrence calculated
44
+ # by the rule returns "August 1st", which is earlier than
45
+ # August 15th. In this context, ~@duration_begin~ is the matter.
46
+ next if date < @duration_begin
47
+
48
+ # Ignore ~first_date~ not to yield twice.
49
+ next if date == @first_date
50
+
51
+ yield date
52
+ end
53
+ end
54
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
55
+
56
+ # Set the new beginning of duration for the recurrence rule.
57
+ #
58
+ # It also controls the underlying frame manager. Since the
59
+ # frame manager is enough smart to avoid unnecessary repetition,
60
+ # there is no problem in setting the date hundred years later.
61
+ #
62
+ # Note that the meaning of calling ~forward_to~ is different
63
+ # from that of setting the ~first_date~ parameter on creation.
64
+ # For example, if a yearly event has *two-years* ~interval~:
65
+ #
66
+ # 1) if first_date is 2021-08-01 and forward_to 2022-08-01,
67
+ # it will create [2021-08-01 2023-08-01 ...]
68
+ #
69
+ # 2) if first_date is 2022-08-01,
70
+ # it will create [2022-08-01 2024-08-01 ...]
71
+ #
72
+ def forward_to(date)
73
+ @frame_manager.forward_to(date)
74
+ @duration_begin = date
75
+ self
76
+ end
77
+
78
+ # Imprement rewind for Enumrator class
79
+ def rewind
80
+ @frame_manager.rewind
81
+ self
82
+ end
83
+
84
+ # Set the new end of duration for the recurrence rule.
85
+ def until(date)
86
+ @duration_until = date
87
+ self
88
+ end
89
+
90
+ private
91
+
92
+ def between_duration?(date)
93
+ (!@duration_begin || @duration_begin <= date) &&
94
+ (!@duration_until || date <= @duration_until)
95
+ end
96
+
97
+ def frame_manager
98
+ raise NotImplementedError
99
+ end
100
+
101
+ def occurrence_in_frame(date)
102
+ raise NotImplementedError
103
+ end
104
+ end
105
+
106
+ ################################################################
107
+ # Enumerate yealy dates by day like: Apr 4th Tue
108
+ class YearlyByDay < Base
109
+ def initialize(first_date:, month:, nth:, wday:, interval: 1)
110
+ super(first_date: first_date, interval: interval)
111
+ @month, @nth, @wday = month, nth, wday
112
+ end
113
+
114
+ private
115
+
116
+ def frame_manager
117
+ DateFrame::Yearly
118
+ end
119
+
120
+ def occurrence_in_frame(date)
121
+ make_date_by_day(year: date.year, month: @month, nth: @nth, wday: @wday)
122
+ rescue ArgumentError
123
+ nil
124
+ end
125
+ end
126
+
127
+ ################################################################
128
+ # Enumerate yealy dates by month-day like: Apr 22
129
+ # s, e = Date.new(2021, 1, 1), Date.new(20100, 12, 31)
130
+ # Enumdate::YearlyByMonthday(start_date: s, end_date: e, month: 4, mday: 22, interval: 2).map(&:to_s)
131
+ # : => [2021-04-22, 2023-04-22, ..., 2099-04-22]
132
+ class YearlyByMonthday < Base
133
+ def initialize(first_date:, month:, mday:, interval: 1)
134
+ super(first_date: first_date, interval: interval)
135
+ @month, @mday = month, mday
136
+ end
137
+
138
+ private
139
+
140
+ def frame_manager
141
+ DateFrame::Yearly
142
+ end
143
+
144
+ def occurrence_in_frame(date)
145
+ Date.new(date.year, @month, @mday)
146
+ rescue Date::Error
147
+ nil
148
+ end
149
+ end
150
+
151
+ ################################################################
152
+ # Enumerate monthly dates by day like: 4th Tue
153
+ class MonthlyByDay < Base
154
+ def initialize(first_date:, nth:, wday:, interval: 1)
155
+ super(first_date: first_date, interval: interval)
156
+ @nth, @wday = nth, wday
157
+ end
158
+
159
+ private
160
+
161
+ def frame_manager
162
+ DateFrame::Monthly
163
+ end
164
+
165
+ def occurrence_in_frame(date)
166
+ make_date_by_day(year: date.year, month: date.month, nth: @nth, wday: @wday)
167
+ rescue Date::Error
168
+ nil
169
+ end
170
+ end
171
+
172
+ ################################################################
173
+ # Enumerate monthly dates by month-day like: 22
174
+ class MonthlyByMonthday < Base
175
+ def initialize(first_date:, mday:, interval: 1)
176
+ super(first_date: first_date, interval: interval)
177
+ @mday = mday
178
+ end
179
+
180
+ private
181
+
182
+ def frame_manager
183
+ DateFrame::Monthly
184
+ end
185
+
186
+ def occurrence_in_frame(date)
187
+ Date.new(date.year, date.month, @mday)
188
+ rescue Date::Error
189
+ nil
190
+ end
191
+ end
192
+
193
+ ################################################################
194
+ # Enumerate weekly dates like: Tue
195
+ class Weekly < Base
196
+ def initialize(first_date:, wday:, interval: 1, wkst: 1)
197
+ super(first_date: first_date, interval: interval, wkst: wkst)
198
+ @wday = wday
199
+ end
200
+
201
+ private
202
+
203
+ def frame_manager
204
+ DateFrame::Weekly
205
+ end
206
+
207
+ # Sun Mon Tue Wed Thu Fri Sat Sun Mon Tue ...
208
+ # 0 1 2 3 4 5 6 0 1 2 ...
209
+ def occurrence_in_frame(date)
210
+ bof = date - ((date.wday - @wkst) % 7)
211
+ candidate = bof + (@wday - bof.wday) % 7
212
+ return candidate if date <= candidate
213
+
214
+ nil
215
+ end
216
+ end
217
+
218
+ ################################################################
219
+ # Enumerate every n days
220
+ class Daily < Base
221
+ def initialize(first_date:, interval: 1)
222
+ super(first_date: first_date, interval: interval)
223
+ end
224
+
225
+ private
226
+
227
+ def frame_manager
228
+ DateFrame::Daily
229
+ end
230
+
231
+ def occurrence_in_frame(date)
232
+ date
233
+ end
234
+ end
235
+
236
+ ################################################################
237
+ # Enumerate dates from list.
238
+ class ByDateList
239
+ include Enumerable
240
+
241
+ def initialize(date_list: [])
242
+ @date_list = date_list
243
+ @duration_until = nil
244
+ end
245
+
246
+ def <<(date)
247
+ @date_list << date
248
+ end
249
+
250
+ def rewind; end
251
+
252
+ def until(date)
253
+ @duration_until = date
254
+ end
255
+
256
+ def forward_to(date)
257
+ @first_date = date
258
+ end
259
+
260
+ def each
261
+ return enum_for(:each) unless block_given?
262
+
263
+ @date_list.sort.each do |date|
264
+ next if @fist_date && date < @first_date
265
+ break if @duration_until && date > @duration_until
266
+
267
+ yield date
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ # SYNOPSIS:
5
+ # + Enumdate::DateFrame::Yearly.new(first_date, interval)
6
+ # + Enumdate::DateFrame::Monthly.new(first_date, interval)
7
+ # + Enumdate::DateFrame::Weekly.new(first_date, interval, wkst)
8
+ # + Enumdate::DateFrame::Daily.new(first_date, interval)
9
+ #
10
+ # Iterate the date in frames of year, month, week, or day,
11
+ # and enumerate the first date of each frame.
12
+ #
13
+ # ~FIRST_DATE~ is an arbitrary date of the first frame.
14
+ #
15
+ # #+begin_src ruby
16
+ # Enumdate::DateFrame::Yearly.new(Date.new(2021, 1, 1), 2).lazy.map(&:to_s).take(3).force
17
+ # # => ["2021-01-01", "2023-01-01", "2025-01-01"]
18
+ #
19
+ # Enumdate::DateFrame::Yearly.new(Date.new(2021, 6, 1), 2).lazy.map(&:to_s).take(3).force
20
+ # # => ["2021-01-01", "2023-01-01", "2025-01-01"]
21
+ #
22
+ # Enumdate::DateFrame::Monthly.new(Date.new(2021, 6, 10), 2).lazy.map(&:to_s).take(3).force
23
+ # # => ["2021-06-01", "2021-08-01", "2021-10-01"]
24
+ # #+end_src
25
+ #
26
+ # ~Enumdate::DateFrame::Weekly~ is sensitive to ~WKST~ param:
27
+ #
28
+ # 2021-06-08 is Tuesday:
29
+ # #+begin_example
30
+ # June 2021
31
+ # Su Mo Tu We Th Fr Sa
32
+ # 30 31 1 2 3 4 5
33
+ # 6 7 8 9 10 11 12
34
+ # 13 14 15 16 17 18 19
35
+ # 20 21 22 23 24 25 26
36
+ # 27 28 29 30 1 2 3
37
+ # #+end_example
38
+ #
39
+ # #+begin_src ruby
40
+ # sun, mon, tue, wed = 0, 1, 2, 3
41
+ #
42
+ # # If week start is Sunday:
43
+ # Enumdate::DateFrame::Weekly.new(Date.new(2021, 6, 8), 2, sun).lazy.map(&:to_s).take(3).force
44
+ # # => ["2021-06-06", "2021-06-20", "2021-07-04"]
45
+ #
46
+ # # Monday (default):
47
+ # Enumdate::DateFrame::Weekly.new(Date.new(2021, 6, 8), 2, mon).lazy.map(&:to_s).take(3).force
48
+ # # => ["2021-06-07", "2021-06-21", "2021-07-05"]
49
+ #
50
+ # # Tuesday:
51
+ # Enumdate::DateFrame::Weekly.new(Date.new(2021, 6, 8), 2, tue).lazy.map(&:to_s).take(3).force
52
+ # # => ["2021-06-08", "2021-06-22", "2021-07-06"]
53
+ #
54
+ # # Wednesday:
55
+ # Enumdate::DateFrame::Weekly.new(Date.new(2021, 6, 8), 2, wed).lazy.map(&:to_s).take(3).force
56
+ # # => ["2021-06-02", "2021-06-16", "2021-06-30"]
57
+ #
58
+ # Enumdate::DateFrame::Daily.new(Date.new(2021, 5, 15), 10).lazy.map(&:to_s).take(3).force
59
+ # # => ["2021-05-15", "2021-05-25", "2021-06-04"]
60
+ #
61
+ # # `forward_to` method is helpful to jump to some specific date before the iteration.
62
+ # Enumdate::DateFrame::Yearly.new(Date.new(2021, 1, 1), 2).forward_to(Date.new(2100, 1, 1))
63
+ # .lazy.map(&:to_s).take(3).force
64
+ # # => ["2101-01-01", "2103-01-01", "2105-01-01"]
65
+ #
66
+ # # Let's see how it differs from simply changing FIRST_DATE:
67
+ # Enumdate::DateFrame::Yearly.new(Date.new(2100, 1, 1), 2).lazy.map(&:to_s).take(3).force
68
+ # # => ["2100-01-01", "2102-01-01", "2104-01-01"]
69
+ # #+end_src
70
+ #
71
+ module DateFrame
72
+ # DateFrame: yearly, monthly, weekly, and daily
73
+ class Base
74
+ include DateHelper
75
+ include Enumerable
76
+
77
+ def initialize(first_date, interval = 1, wkst = 1)
78
+ @first_date, @interval, @wkst = first_date, interval, wkst
79
+ rewind
80
+ end
81
+
82
+ def each
83
+ return enum_for(:each) unless block_given?
84
+
85
+ loop do
86
+ yield @current_frame_date
87
+ @current_frame_date = next_frame_start(@current_frame_date)
88
+ end
89
+ end
90
+
91
+ # Imprement rewind for Enumrator class
92
+ def rewind
93
+ @current_frame_date = beginning_of_frame(@first_date)
94
+ self
95
+ end
96
+
97
+ # Go forward to the frame in which DATE is involved
98
+ def forward_to(date)
99
+ rewind # reset @current_frame_date
100
+ frames = frames_between(@current_frame_date, date)
101
+ cycles = (frames + (@interval - 1)) / @interval
102
+ @current_frame_date = next_frame_start(@current_frame_date, cycles) if cycles.positive?
103
+ self
104
+ end
105
+
106
+ private
107
+
108
+ def next_frame_start(current_frame_date, cycles = 1)
109
+ raise NotImplementedError
110
+ end
111
+
112
+ def beginning_of_frame(date)
113
+ raise NotImplementedError
114
+ end
115
+
116
+ def frames_between(date1, date2)
117
+ raise NotImplementedError
118
+ end
119
+ end
120
+
121
+ # Dummy date frame
122
+ class Dummy < Base
123
+ end
124
+
125
+ # Yearly date frame
126
+ class Yearly < Base
127
+ private
128
+
129
+ def next_frame_start(current_frame_date, cycles = 1)
130
+ current_frame_date >> (@interval * 12 * cycles)
131
+ end
132
+
133
+ def beginning_of_frame(date)
134
+ beginning_of_year(date)
135
+ end
136
+
137
+ def frames_between(date1, date2)
138
+ years_between(date1, date2)
139
+ end
140
+ end
141
+
142
+ # Monthly date frame
143
+ class Monthly < Base
144
+ private
145
+
146
+ def next_frame_start(current_frame_date, cycles = 1)
147
+ current_frame_date >> (@interval * cycles)
148
+ end
149
+
150
+ def beginning_of_frame(date)
151
+ beginning_of_month(date)
152
+ end
153
+
154
+ def frames_between(date1, date2)
155
+ months_between(date1, date2)
156
+ end
157
+ end
158
+
159
+ # Weekly date frame
160
+ class Weekly < Base
161
+ private
162
+
163
+ def next_frame_start(current_frame_date, cycles = 1)
164
+ current_frame_date + (@interval * 7 * cycles)
165
+ end
166
+
167
+ def beginning_of_frame(date)
168
+ beginning_of_week(date, @wkst)
169
+ end
170
+
171
+ def frames_between(date1, date2)
172
+ (beginning_of_frame(date2) - beginning_of_frame(date1)) / 7
173
+ end
174
+ end
175
+
176
+ # Daily date frame
177
+ class Daily < Base
178
+ private
179
+
180
+ def next_frame_start(current_frame_date, cycles = 1)
181
+ current_frame_date + (@interval * cycles)
182
+ end
183
+
184
+ def beginning_of_frame(date)
185
+ date
186
+ end
187
+
188
+ def frames_between(date1, date2)
189
+ date2 - date1
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ # Helper for date-calculation.
5
+ module DateHelper
6
+ private
7
+
8
+ # Make a date by DAY like ``1st Wed of Nov, 1999''.
9
+ # caller must make sure:
10
+ # YEAR and MONTH must be valid
11
+ # NTH must be < 0 or > 0
12
+ # WDAY must be 0:Sun .. 6:Sat
13
+ #
14
+ # raise ArgumentError if no date matches. for example:
15
+ # no 5th Saturday exists on April 2010.
16
+ #
17
+ def make_date_by_day(year:, month:, nth:, wday:)
18
+ direction = nth.positive? ? 1 : -1
19
+
20
+ edge = Date.new(year, month, direction)
21
+ ydiff = nth - direction
22
+ xdiff = direction * ((direction * (wday - edge.wday)) % 7)
23
+ mday = edge.mday + ydiff * 7 + xdiff
24
+
25
+ raise ArgumentError if mday < 1
26
+
27
+ Date.new(year, month, mday)
28
+ end
29
+
30
+ def beginning_of_year(date)
31
+ date.class.new(date.year, 1, 1)
32
+ end
33
+
34
+ def beginning_of_month(date)
35
+ date.class.new(date.year, date.month, 1)
36
+ end
37
+
38
+ def beginning_of_week(date, wkst = 1)
39
+ date - ((date.wday - wkst) % 7)
40
+ end
41
+
42
+ def years_between(date1, date2)
43
+ date2.year - date1.year
44
+ end
45
+
46
+ def months_between(date1, date2)
47
+ (date2.year * 12 + date2.month) - (date1.year * 12 + date1.month)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ # Create new Enumerator merging multiple enumerators.
5
+ #
6
+ # All enumerators should yield objects that respond to `<=>` method.
7
+ # enums = (EnumMerger.new << enum1 << enum2).to_enum
8
+ #
9
+ class EnumMerger
10
+ include Enumerable
11
+
12
+ def initialize
13
+ @enumerators = []
14
+ end
15
+
16
+ # Imprement each for Enumrator class
17
+ def each
18
+ return enum_for(:each) unless block_given?
19
+
20
+ loop do
21
+ yield next_minimum(@enumerators)
22
+ end
23
+ end
24
+
25
+ # Imprement rewind for Enumrator class
26
+ def rewind
27
+ @enumerators.map(&:rewind)
28
+ end
29
+
30
+ # Add enumerator
31
+ def <<(enumerator)
32
+ @enumerators << enumerator.to_enum
33
+ self
34
+ end
35
+
36
+ private
37
+
38
+ # Yield next minimum value
39
+ def next_minimum(enumerators)
40
+ raise StopIteration if enumerators.empty?
41
+
42
+ # Could raise StopIteration
43
+ minimum_enumrator(enumerators).next
44
+ end
45
+
46
+ def minimum_enumrator(enumerators)
47
+ min_e, min_v = enumerators.first, nil
48
+ enumerators.each do |e|
49
+ v = e.peek
50
+ min_e, min_v = e, v if min_v.nil? || v < min_v
51
+ rescue StopIteration
52
+ # do nothing
53
+ end
54
+ min_e
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumdate
4
+ VERSION = "0.1.0"
5
+ end
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: enumdate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yoshinari Nomura
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-08-22 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - nom@quickhack.net
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".github/workflows/main.yml"
21
+ - ".gitignore"
22
+ - ".rubocop.yml"
23
+ - ".solargraph.yml"
24
+ - Gemfile
25
+ - LICENSE.txt
26
+ - README.org
27
+ - Rakefile
28
+ - bin/console
29
+ - bin/setup
30
+ - enumdate.gemspec
31
+ - lib/enumdate.rb
32
+ - lib/enumdate/date_enumerator.rb
33
+ - lib/enumdate/date_frame.rb
34
+ - lib/enumdate/date_helper.rb
35
+ - lib/enumdate/enum_merger.rb
36
+ - lib/enumdate/version.rb
37
+ homepage: https://github.com/yoshinari-nomura/enumdate
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ homepage_uri: https://github.com/yoshinari-nomura/enumdate
42
+ source_code_uri: https://github.com/yoshinari-nomura/enumdate
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: 2.5.0
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 3.2.22
59
+ signing_key:
60
+ specification_version: 4
61
+ summary: Enumerator for recurring dates.
62
+ test_files: []