├── .rspec ├── .editorconfig ├── .gitignore ├── gems.rb ├── periodical.gemspec ├── .github └── workflows │ └── development.yml ├── lib ├── periodical.rb └── periodical │ ├── version.rb │ ├── duration.rb │ ├── period.rb │ └── filter.rb ├── spec ├── spec_helper.rb └── periodical │ ├── duration_spec.rb │ ├── filter_spec.rb │ └── period_spec.rb └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /gems.locked 3 | /pkg/ 4 | /tmp/ 5 | 6 | # rspec failure tracking 7 | .rspec_status 8 | 9 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem "rspec", "~> 3.4.0" 7 | end 8 | -------------------------------------------------------------------------------- /periodical.gemspec: -------------------------------------------------------------------------------- 1 | 2 | require_relative "lib/periodical/version" 3 | 4 | Gem::Specification.new do |spec| 5 | spec.name = "periodical" 6 | spec.version = Periodical::VERSION 7 | 8 | spec.summary = "Periodical is a simple framework for working with durations and periods." 9 | spec.authors = ["Samuel Williams"] 10 | spec.license = "MIT" 11 | 12 | spec.homepage = "https://github.com/ioquatix/periodical" 13 | 14 | spec.metadata = { 15 | "funding_uri" => "https://github.com/sponsors/ioquatix/", 16 | } 17 | 18 | spec.files = Dir.glob('{lib}/**/*', File::FNM_DOTMATCH, base: __dir__) 19 | 20 | spec.required_ruby_version = ">= 2.0" 21 | 22 | spec.add_development_dependency "bake-bundler" 23 | spec.add_development_dependency "bake-modernize" 24 | end 25 | -------------------------------------------------------------------------------- /.github/workflows/development.yml: -------------------------------------------------------------------------------- 1 | name: Development 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{matrix.os}}-latest 8 | continue-on-error: ${{matrix.experimental}} 9 | 10 | strategy: 11 | matrix: 12 | os: 13 | - ubuntu 14 | - macos 15 | 16 | ruby: 17 | - 2.5 18 | - 2.6 19 | - 2.7 20 | 21 | experimental: [false] 22 | env: [""] 23 | 24 | include: 25 | - os: ubuntu 26 | ruby: truffleruby 27 | experimental: true 28 | - os: ubuntu 29 | ruby: jruby 30 | experimental: true 31 | - os: ubuntu 32 | ruby: head 33 | experimental: true 34 | 35 | steps: 36 | - uses: actions/checkout@v2 37 | - uses: ruby/setup-ruby@v1 38 | with: 39 | ruby-version: ${{matrix.ruby}} 40 | 41 | - name: Install dependencies 42 | run: ${{matrix.env}} bundle install --without development 43 | 44 | - name: Run tests 45 | timeout-minutes: 5 46 | run: ${{matrix.env}} bundle exec rspec 47 | -------------------------------------------------------------------------------- /lib/periodical.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Samuel G. D. Williams. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | require 'periodical/period' 22 | require 'periodical/duration' -------------------------------------------------------------------------------- /lib/periodical/version.rb: -------------------------------------------------------------------------------- 1 | # Copyright, 2012, by Samuel G. D. Williams. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | module Periodical 22 | VERSION = "1.2.0" 23 | end 24 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # Copyright, 2020, by Samuel G. D. Williams. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | require "bundler/setup" 22 | 23 | RSpec.configure do |config| 24 | # Enable flags like --only-failures and --next-failure 25 | config.example_status_persistence_file_path = ".rspec_status" 26 | 27 | # Disable RSpec exposing methods globally on `Module` and `main` 28 | config.disable_monkey_patching! 29 | 30 | config.expect_with :rspec do |c| 31 | c.syntax = :expect 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/periodical/duration.rb: -------------------------------------------------------------------------------- 1 | # Copyright, 2012, by Samuel G. D. Williams. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | require 'date' 22 | 23 | module Periodical 24 | Duration = Struct.new(:from, :to) do 25 | def days 26 | to - from 27 | end 28 | 29 | def weeks 30 | days / 7 31 | end 32 | 33 | def whole_months 34 | (to.year * 12 + to.month) - (from.year * 12 + from.month) 35 | end 36 | 37 | def months 38 | whole = self.whole_months 39 | 40 | partial_start = from >> whole 41 | partial_end = from >> whole + 1 42 | 43 | return whole + (to - partial_start) / (partial_end - partial_start) 44 | end 45 | 46 | def whole_years 47 | to.year - from.year 48 | end 49 | 50 | def years 51 | whole = self.whole_years 52 | 53 | partial_start = from >> (whole * 12) 54 | partial_end = from >> ((whole + 1) * 12) 55 | 56 | return whole + (to - partial_start) / (partial_end - partial_start) 57 | end 58 | 59 | # Calculate the number of periods between from and to 60 | def / period 61 | self.send(period.unit) / period.count 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/periodical/duration_spec.rb: -------------------------------------------------------------------------------- 1 | # Copyright, 2012, by Samuel G. D. Williams. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | require 'periodical' 22 | 23 | RSpec.describe Periodical::Duration do 24 | it "should measure durations correctly" do 25 | duration = Periodical::Duration.new(Date.parse("2010-01-01"), Date.parse("2010-02-01")) 26 | expect(duration.days).to be == 31 27 | expect(duration.weeks).to be == Rational(31, 7) 28 | expect(duration.months).to be == 1 29 | expect(duration.years).to be == Rational(31, 365) 30 | 31 | expect(duration.whole_months).to be == 1 32 | expect(duration.whole_years).to be == 0 33 | end 34 | 35 | it "should compute the correct number of weeks" do 36 | duration = Periodical::Duration.new(Date.parse("2010-01-01"), Date.parse("2010-02-01")) 37 | period = Periodical::Period.new(2, :weeks) 38 | 39 | expect(duration / period).to be == Rational(31, 14) 40 | end 41 | 42 | it "should compute the correct number of months" do 43 | duration = Periodical::Duration.new(Date.parse("2010-01-01"), Date.parse("2011-03-01")) 44 | period = Periodical::Period.new(2, :months) 45 | 46 | expect(duration / period).to be == Rational(14, 2) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/periodical/filter_spec.rb: -------------------------------------------------------------------------------- 1 | # Copyright, 2012, by Samuel G. D. Williams. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | require 'periodical/filter' 22 | 23 | RSpec.describe Periodical::Filter do 24 | it "should select at most 3 days worth of data" do 25 | dates = [ 26 | Date.parse("2010-01-01"), 27 | Date.parse("2010-01-02"), 28 | Date.parse("2010-01-03"), 29 | Date.parse("2010-01-04"), 30 | Date.parse("2010-01-05"), 31 | Date.parse("2010-01-06"), 32 | ] 33 | 34 | policy = Periodical::Filter::Policy.new 35 | policy << Periodical::Filter::Daily.new(3) 36 | 37 | selected, rejected = policy.filter(dates) 38 | 39 | expect(selected).to include(*dates.first(3)) 40 | expect(rejected).to include(*dates.last(3)) 41 | end 42 | 43 | it "should keep youngest" do 44 | dates = [ 45 | Date.parse("2010-01-01"), 46 | Date.parse("2010-01-02"), 47 | ] 48 | 49 | policy = Periodical::Filter::Policy.new 50 | policy << Periodical::Filter::Monthly.new(1) 51 | 52 | selected, rejected = policy.filter(dates, :keep => :new) 53 | expect(selected.count).to be 1 54 | expect(rejected.count).to be 1 55 | 56 | # Keep oldest is the default policy 57 | expect(selected).to be_include(dates[1]) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/periodical/period_spec.rb: -------------------------------------------------------------------------------- 1 | # Copyright, 2012, by Samuel G. D. Williams. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | require 'periodical' 22 | 23 | RSpec.describe Periodical::Period do 24 | it "should advance by 1 month" do 25 | duration = Periodical::Duration.new(Date.parse("2010-01-01"), Date.parse("2011-01-01")) 26 | 27 | period = Periodical::Period.new(1, :months) 28 | 29 | expect(period.advance(duration.from, 12)).to be == duration.to 30 | end 31 | 32 | it "should parse a singular period" do 33 | period = Periodical::Period.parse("years") 34 | 35 | expect(period.count).to be == 1 36 | expect(period.unit).to be == :years 37 | end 38 | 39 | it "should parse a multiple count period" do 40 | period = Periodical::Period.parse("5 days") 41 | 42 | expect(period.count).to be == 5 43 | expect(period.unit).to be == :days 44 | end 45 | 46 | it "can load nil" do 47 | expect(Periodical::Period.load(nil)).to be == nil 48 | expect(Periodical::Period.load("")).to be == nil 49 | end 50 | 51 | it "can dump nil" do 52 | expect(Periodical::Period.dump(nil)).to be == nil 53 | end 54 | 55 | it "can load string" do 56 | expect(Periodical::Period.load("5 weeks")).to be == Periodical::Period.new(5, :weeks) 57 | end 58 | 59 | it "can dump nil" do 60 | expect(Periodical::Period.dump(Periodical::Period.new(5, :weeks))).to be == "5 weeks" 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/periodical/period.rb: -------------------------------------------------------------------------------- 1 | # Copyright, 2012, by Samuel G. D. Williams. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | module Periodical 22 | Period = Struct.new(:count, :unit) do 23 | VALID_UNITS = [:days, :weeks, :months, :years].freeze 24 | 25 | # Plural is preferred, as in "1 or more days". 26 | def initialize(count = 1, unit = :days) 27 | super(count, unit) 28 | end 29 | 30 | def to_s 31 | if self.count != 1 32 | "#{self.count} #{self.unit}" 33 | else 34 | self.unit.to_s 35 | end 36 | end 37 | 38 | def advance(date, multiple = 1) 39 | raise TypeError unless date.is_a?(Date) 40 | 41 | self.send("advance_#{unit}", date, multiple * self.count) 42 | end 43 | 44 | private 45 | 46 | def advance_days(date, count) 47 | date + count 48 | end 49 | 50 | def advance_weeks(date, count) 51 | advance_days(date, count*7) 52 | end 53 | 54 | def advance_months(date, count) 55 | date >> count 56 | end 57 | 58 | def advance_years(date, count) 59 | advance_months(date, count*12) 60 | end 61 | 62 | class << self 63 | # Accepts strings in the format of "2 weeks" or "weeks" 64 | def parse(string) 65 | parts = string.split(/\s+/, 2) 66 | 67 | if parts.size == 1 68 | count = 1 69 | unit = parts[0] 70 | else 71 | count, unit = parts 72 | end 73 | 74 | self.new(count.to_i, unit.to_sym) 75 | end 76 | 77 | def load(string) 78 | if string 79 | string = string.strip 80 | 81 | parse(string) unless string.empty? 82 | end 83 | end 84 | 85 | def dump(period) 86 | period.to_s if period 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Periodical 2 | 3 | Periodical is a simple framework for working with durations and periods. A duration measures a range of time bounded by a `from` date and `to` date. A period is a relative unit of time such as `4 weeks`. 4 | 5 | [![Development Status](https://github.com/ioquatix/periodical/workflows/Development/badge.svg)](https://github.com/ioquatix/periodical/actions?workflow=Development) 6 | 7 | ## Motivation 8 | 9 | The original idea for this library came from a [Python script which performed backup rotation](http://www.scottlu.com/Content/Snapfilter.html) from 2009. In particular, I thought it had a novel way to retain backups according to a given policy (e.g. one backup every year for 10 years, one backup every month for 12 months, one backup every week for 8 weeks, one backup every day for 30 days). This is done by constructing a special slot based hash structure with keys based on the date being stored. This functionality is used by [LSync](https://github.com/ioquatix/LSync) for performing backup rotation (i.e. deleting old backups). 10 | 11 | In addition, I had a need to implement periodical billing in [Financier](https://github.com/ioquatix/financier). Not only can this gem advance a date by a given period, it can compute the number of periods between two dates. This is useful for invoicing, say, once every 6 months for a weekly or monthly service. 12 | 13 | ## Installation 14 | 15 | Add this line to your application's Gemfile: 16 | 17 | ``` shell 18 | $ bundle add periodical 19 | ``` 20 | 21 | ## Usage 22 | 23 | The main use case for this framework involves periodic billing or accounting (e.g. calculating fortnightly rental payments). 24 | 25 | ``` ruby 26 | duration = Periodical::Duration.new(Date.parse("2010-01-01"), Date.parse("2010-02-01")) 27 | period = Periodical::Period.new(2, :weeks) 28 | 29 | # How many periods in the duration? 30 | count = duration / period 31 | 32 | # Calculate the date which is 2 * (2 weeks) 33 | next = period.advance(duration.from, 2) 34 | ``` 35 | 36 | ## Contributing 37 | 38 | 1. Fork it 39 | 2. Create your feature branch (`git checkout -b my-new-feature`) 40 | 3. Commit your changes (`git commit -am 'Add some feature'`) 41 | 4. Push to the branch (`git push origin my-new-feature`) 42 | 5. Create new Pull Request 43 | 44 | ## License 45 | 46 | Released under the MIT license. 47 | 48 | Copyright, 2010, 2014, 2016, by [Samuel G. D. Williams](http://www.codeotaku.com/samuel-williams). 49 | 50 | Permission is hereby granted, free of charge, to any person obtaining a copy 51 | of this software and associated documentation files (the "Software"), to deal 52 | in the Software without restriction, including without limitation the rights 53 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 54 | copies of the Software, and to permit persons to whom the Software is 55 | furnished to do so, subject to the following conditions: 56 | 57 | The above copyright notice and this permission notice shall be included in 58 | all copies or substantial portions of the Software. 59 | 60 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 61 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 62 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 63 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 64 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 65 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 66 | THE SOFTWARE. 67 | -------------------------------------------------------------------------------- /lib/periodical/filter.rb: -------------------------------------------------------------------------------- 1 | # Copyright, 2012, by Samuel G. D. Williams. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | require 'date' 22 | require 'set' 23 | 24 | module Periodical 25 | # A filter module for backup rotation like behaviour, e.g. keep every hour for 24 hours, every day for 30 days, etc. 26 | module Filter 27 | # Keep count sorted objects per period. 28 | class Period 29 | # Given times a and b, should we prefer a? 30 | ORDER = { 31 | # We want `a` if `a` < `b`, i.e. it's older. 32 | old: ->(a, b){a < b}, 33 | 34 | # We want `a` if `a` > `b`, i.e. it's newer. 35 | new: ->(a, b){a > b} 36 | } 37 | 38 | # @param count the number of items we should retain. 39 | def initialize(count) 40 | @count = count 41 | end 42 | 43 | # @param order can be a key in ORDER or a lambda. 44 | # @param block is applied to the value and should typically return a Time instance. 45 | def filter(values, keep: :old, &block) 46 | slots = {} 47 | 48 | keep = ORDER.fetch(keep, keep) 49 | 50 | values.each do |value| 51 | time = block_given? ? yield(value) : value 52 | 53 | granular_key = key(time) 54 | 55 | # We filter out this value if the slot is already full and we prefer the existing value. 56 | if existing_value = slots[granular_key] 57 | existing_time = block_given? ? yield(existing_value) : existing_value 58 | next if keep.call(existing_time, time) 59 | end 60 | 61 | slots[granular_key] = value 62 | end 63 | 64 | sorted_values = slots.values.sort 65 | 66 | return sorted_values.first(@count) 67 | end 68 | 69 | def key(t) 70 | raise NotImplementedError 71 | end 72 | 73 | def mktime(year, month=1, day=1, hour=0, minute=0, second=0) 74 | return Time.new(year, month, day, hour, minute, second) 75 | end 76 | 77 | attr :count 78 | end 79 | 80 | class Hourly < Period 81 | def key(t) 82 | mktime(t.year, t.month, t.day, t.hour) 83 | end 84 | end 85 | 86 | class Daily < Period 87 | def key(t) 88 | mktime(t.year, t.month, t.day) 89 | end 90 | end 91 | 92 | class Weekly < Period 93 | def key(t) 94 | mktime(t.year, t.month, t.day) - (t.wday * 3600 * 24) 95 | end 96 | end 97 | 98 | class Monthly < Period 99 | def key(t) 100 | mktime(t.year, t.month) 101 | end 102 | end 103 | 104 | class Quarterly < Period 105 | def key(t) 106 | mktime(t.year, (t.month - 1) / 3 * 3 + 1) 107 | end 108 | end 109 | 110 | class Yearly < Period 111 | def key(t) 112 | mktime(t.year) 113 | end 114 | end 115 | 116 | class Policy 117 | def initialize 118 | @periods = {} 119 | end 120 | 121 | def <<(period) 122 | @periods[period.class] = period 123 | end 124 | 125 | def filter(values, **options, &block) 126 | filtered_values = Set.new 127 | 128 | @periods.values.each do |period| 129 | filtered_values += period.filter(values, **options, &block) 130 | end 131 | 132 | return filtered_values, (Set.new(values) - filtered_values) 133 | end 134 | 135 | attr :periods 136 | end 137 | end 138 | end 139 | --------------------------------------------------------------------------------