diff --git a/.gitignore b/.gitignore index 23ec656..e3bc6ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/doc/ +/docs/ /lib/ /bin/ /.shards/ diff --git a/README.md b/README.md index e962740..bc2c8b8 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,12 @@ -[![Linux CI](https://github.com/crystallabs/virtualtime/workflows/Linux%20CI/badge.svg)](https://github.com/crystallabs/virtualtime/actions?query=workflow%3A%22Linux+CI%22+event%3Apush+branch%3Amaster) -[![Version](https://img.shields.io/github/tag/crystallabs/virtualtime.svg?maxAge=360)](https://github.com/crystallabs/virtualtime/releases/latest) -[![License](https://img.shields.io/github/license/crystallabs/virtualtime.svg)](https://github.com/crystallabs/virtualtime/blob/master/LICENSE) +[![Linux CI](https://github.com/crystallabs/virtualdate/workflows/Linux%20CI/badge.svg)](https://github.com/crystallabs/virtualdate/actions?query=workflow%3A%22Linux+CI%22+event%3Apush+branch%3Amaster) +[![Version](https://img.shields.io/github/tag/crystallabs/virtualdate.svg?maxAge=360)](https://github.com/crystallabs/virtualdate/releases/latest) +[![License](https://img.shields.io/github/license/crystallabs/virtualdate.svg)](https://github.com/crystallabs/virtualdate/blob/master/LICENSE) -VirtualTime is a time matching class for Crystal. -It is used for complex and flexible matching of dates and times, primarily for calendar, scheduling, and reminding purposes. +VirtualDate is a time scheduling component for Crystal. It is a sibling project of [virtualtime](https://github.com/crystallabs/virtualtime). +It is used for complex and flexible, and often recurring, time/event scheduling. -For example: - -```cr -vt = VirtualTime.new -vt.year = 2020..2030 -vt.day = -8..-1 -vt.day_of_week = [6,7] -vt.hour = 12..16 - -time = Time.local - -vt.matches? time -``` - -That `VirtualTime` instance will match any `Time` that is: - -- Between years 2020 and 2030, inclusively -- In the last 7 days of each/any month (day = -8..-1; negative values count from the end) -- Falling on Saturday or Sunday (day_of_week = 6 or 7) -- And between hours noon and 4PM (hour = 12..16) +VirtualTime from the other shard implements the low-level time matching component. +VirtualDate implements the high-level part, the actual items one might want to schedule. # Installation @@ -32,127 +14,153 @@ Add the following to your application's "shard.yml": ``` dependencies: - virtualtime: - github: crystallabs/virtualtime - version ~> 1.0 + virtualdate: + github: crystallabs/virtualdate + version: ~> 1.0 ``` And run `shards install` or just `shards`. # Introduction -Think of class `VirtualTime` as of a very flexible time specification that can be used to -match against Crystal's `Time` instances. - -Crystal's `struct Time` has all its fields (year, month, day, hour, minute, second, nanosecond) set -to a specific numeric value. Even if some of its fields aren't required in the constructor, -internally they still get initialized to 0, 1, or other suitable value. - -As such, `Time` instances always represent specific dates and times ("materialized" dates and times). - -On the other hand, `VirtualTime`s do not have to represent any specific points in time (although they can -be set or converted so that they do); they are instead intended for conveniently matching broader sets of -values. VirtualTime instances contain the following properties: - -1. **Year** (0..9999) -1. **Month** (1..12) -1. **Day** (1..31) -1. **Week number of year** (0..53) -1. **Day of week** (1..7, Monday == 1) -1. **Day of year** (1..366) -1. **Hour** (0..23) -1. **Minute** (0..59) -1. **Second** (0..59) -1. **Millisecond** (0..999) -1. **Nanosecond** (0..999_999_999) - -And each of these properties can have a value of the following types: - -1. **Nil** (no setting), to always match as a default value -1. **Boolean**, to always specifically match (`true`) or fail (`false`) -1. **Int32**, to match a specific value such as 5, 12, 2023, -1, or -5 -1. **Array of Int32s**, such as [1,2,10,-1] to match any value in list -1. **Range of Int32..Int32**, such as `10..20` to match any value in range -1. **Range with step**, e.g. `day: (10..20).step(2)`, to match all even days between 10th and 20th -1. **Proc**, to match a value if the return value from calling a proc is `true` - -All properties (that are specified, i.e. not nil) must match for the match to succeed. - -This `VirtualTime` object can then be used for matching arbitrary `Time`s against it, to check if -they match. - -The described syntax allows for specifying simple but functionally intricate -rules, of which just some of them are: - -```txt -day=-1 -- matches last day in month -day_of_week=6, day=24..31 -- matches last Saturday in month -day_of_week=1..5, day=-1 -- matches last day of month if it is a workday -``` +`VirtualTime` is a shard which implements the low-level component; a class, closely related to the Time struct, +that is used for matching Times. -Negative values count from the end of the range. Typical end values are 7, 12, 30/31, 365/366, -23, 59, and 999, and virtualtime implicitly knows which one to apply in every case. For example, -a day of `-1` would always match the last day of the month, be that 28th, 29th, 30th, or 31st in a -particular case. - -An interesting case is week number, which is calculated as number of Mondays in the year. -The first Monday in a year starts week number 1, but not every year starts on Monday so up to -the first 3 days of new year can still technically belong to the last week of the previous year. -That means it -is possible for this field to have values between 0 and 53. Value 53 indicates a week that has -started in one year (53rd Monday seen in a year), but up to 3 of its days will overflow into -the new year. Similarly, a value 0 matches up to the first 3 days (which inevitably must be -Friday, Saturday, and/or Sunday) of the new year that belong to the week started in the -previous year. - -Another example: - -```cr -vt = VirtualTime.new - -vt.month = 3 # Month of March -vt.day = [1,-1] # First and last day of every month -vt.hour = (10..20) -vt.minute = (0..59).step(2) # Every other (even) minute in an hour -vt.second = true # Unconditional match -vt.millisecond = ->( val : Int32) { true } # Will match any value as block returns true -``` +`VirtualDate` is the high-level component. It represents actual things you want to schedule and/or their reminders. + +The class is intentionally called `VirtualDate` not to imply a particular type or purpose +(i.e. it can be a task, event, recurring appointment, reminder, etc.) + +Likewise, it does not contain any task/event-specific properties -- it only concerns itself with +the matching and scheduling aspect. + +For a schedulable item it is not enough to have just one `VirtualTime` that controls +when that item is active/scheduled (or simply "on" in virtualtime's terminology). + +Instead, for additional flexibility, at a minimum you might want to be able to specify multiple +`VirtualTimes` at which the item is on, and specify an omit list when an item +should not be on (e.g. on weekends or public holidays). + +Also, if an item would fall on an omitted date or time, then it might be desired to automatically +reschedule it by shifting it by certain amount of time before or after the original time. + +Thus, `VirtualDate` has the following properties: + +- `start`, an absolute start time, before which the VirtualDate is never on +- `stop`, an absolute end time, after which the VirtualDate is never on + +- `due`, a list of VirtualTimes on which the VirtualDate is on +- `omit`, a list of VirtualTimes on which the VirtualDate is omitted (not on) +- `shift`, governing whether, and by how much time, the VirtualDate should be shifted if it falls on an omitted date/time +- `max_shift`, a maximum Time::Span by which the VirtualDate can be shifted before being considered unschedulable +- `max_shifts`, a maximum number of shift attempts to make in an attempt to find a suitable rescheduled date and time + +- `on`, a property which overrides all other VirtualDate's fields and calculations and directly sets VirtualDate's `on` status + +If the item's list of due dates is empty, it is considered as always "on". +If the item's list of omit dates is empty, it is considered as never omitted. -# Materialization +A value of `shift` can be nil, `Boolean`, or`Time::Span`. Nil instructs that event should not be rescheduled, +and to simply treat it as not scheduled on a particular date. A `Boolean` explicitly marks the item as scheduled or rejected +when it falls on an omitted time. A `Time::Span` implies that rescheduling should be attempted and controls by +how much time the item should be shifted (into the past or future) on every attempt. -VirtualTimes sometimes need to be "materialized" for -the purpose of display, calculation, comparison, or conversion. An obvious such case -which happens implicitly is when `to_time()` is invoked on a VT, because a Time object -must have all of its fields set. +If there are multiple `VirtualDate`s set for a field, e.g. for `due` date, the matches are logically OR-ed; +one match is enough for the field to match. -Because VirtualTimes can be very broadly defined, often times there are many equal -choices to which VTs can be materialized. To avoid the problem of too many choices, -materialization takes as argument a time hint, -and the materialized time will be as close as possible to that time. +# Usage -For example: +## Matching + +Let's start with creating a VirtualDate: ```crystal -vt= VirtualTime.new +vd = VirtualDate.new + +# Create a VirtualTime that matches every other day from Mar 10 to Mar 20: +due_march = VirtualTime.new +due_march.month = 3 +due_march.day = (10..20).step 2 + +# Add this VirtualTime as due date to vd: +vd.due << due_march + +# Create a VirtualTime that matches Mar 20 specifically. We will use this to actually omit +# the event on that day: +omit_march_20 = VirtualTime.new +omit_march_20.month = 3 +omit_march_20.day = 20 + +# Add this VirtualTime as omit date to vd: +vd.omit << omit_march_20 -# These fields will be used as-is since they have a value: -vt.year= 2018 -vt.day= 15 -vt.hour= 0 +# If event falls on an omitted date, try rescheduling it for 2 days later: +vd.shift = 2.days +``` + +Now we can check when the vd is due and when it is not (ignore the `Time[]` syntax): + +```crystal +# VirtualDate is not due on Feb 15, 2017 because that's not in March: +p vd.on?( Time["2017-02-15"]) # ==> false -# While others (which are nil) will have their value inserted from the "hint" object: -hint= Time.local # 2023-12-09 23:23:26.837441132 +01:00 Local +# VirtualDate is not due on Mar 15, 2017 because that's not a day of +# March 10, 12, 14, 16, 18, or 20: +p vd.on?( Time["2017-03-15"]) # ==> false -vt.materialize(hint).to_tuple # ==> {2018, 12, 15, nil, nil, nil, 0, 12, 54, nil, 837441132, nil} +# VirtualDate is due on Mar 16, 2017: +p vd.on?( Time["2017-03-16"]) # ==> true + +# VirtualDate is due on Mar 18, 2017: +p vd.on?( Time["2017-03-18"]) # ==> true + +# And it is due on any Mar 18, doesn't need to be in 2017: +p vd.on?( Time["2023-03-18"]) # ==> true + +# But it is not due on Mar 20, 2017, because that date is omitted, and the system will give us +# a span of time (offset) when it can be scheduled. Based on our reschedule settings above, this +# will be a span for 2 days later. +p vd.on?( VirtualDate["2017-03-20"]) # ==> # + +# Asking whether the vd is due on the rescheduled date (Mar 22) will tell us no, because currently +# rescheduled dates are not counted as due/on dates: +p vd.on?( VirtualDate["2017-03-22"]) # ==> nil +``` + +Here's another example of a VirtualDate that is due on every other day in March, but if it falls +on a weekend it is ignored: + +```crystal +vd = VirtualDate.new + +# Create a VirtualTime that matches every other (every even) day in March: +due_march = VirtualTime.new +due_march.month = 3 +due_march.day = (2..31).step 2 +vd.due << due_march + +# But on weekends it should not be scheduled: +not_due_weekend = VirtualTime.new +not_due_weekend.day_of_week = [6,7] +vd.omit << not_due_weekend + +# If item falls on an omitted day, consider it as not scheduled (don't try rescheduling): +vd.shift = nil + +# Now let's check when it is due and when not in March: +# (Do this by printing a list for days 1 - 31): +(1..31).each do |d| + p "Mar-#{d} = #{vd.on?( Time.local(2023, 3, d)}" +end ``` -# Tests +## Scheduling -Run `crystal spec` or just `crystal s`. +TODO (and include note on rbtree and list of upcoming events) -# API Documentation +## Reminding -Run `crystal docs` or `crystal do; firefox ./docs/index.html`. +TODO (note: reminder = VirtualDate::Reminder) # Other Projects diff --git a/shard.yml b/shard.yml index 9cd4eec..80aeacf 100644 --- a/shard.yml +++ b/shard.yml @@ -1,4 +1,4 @@ -name: virtualtime +name: virtualdate version: 1.0.0 authors: @@ -6,4 +6,9 @@ authors: crystal: 0.35.1 -license: GPL-3.0 +license: AGPL-3.0 + +dependencies: + virtualtime: + github: crystallabs/virtualtime + version: ~> 1.0 diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr deleted file mode 100644 index b65af50..0000000 --- a/spec/spec_helper.cr +++ /dev/null @@ -1,2 +0,0 @@ -require "spec" -require "../src/virtualtime" diff --git a/spec/virtualdate_spec.cr b/spec/virtualdate_spec.cr new file mode 100644 index 0000000..3993105 --- /dev/null +++ b/spec/virtualdate_spec.cr @@ -0,0 +1,5 @@ +require "spec" +require "../src/virtualdate" + +describe VirtualTime do +end diff --git a/spec/virtualtime_spec.cr b/spec/virtualtime_spec.cr deleted file mode 100644 index 68163fc..0000000 --- a/spec/virtualtime_spec.cr +++ /dev/null @@ -1,102 +0,0 @@ -require "./spec_helper" - -describe VirtualTime do - it "can be initialized" do - a = VirtualTime.new - a.year.should eq nil - a.month.should eq nil - a.day.should eq nil - a.day_of_week.should eq nil - a.location.should eq nil - end - it "supports all 7 documented types of values" do - a = VirtualTime.new - a.year = nil # Remains unspecified, matches everything it is compared with - a.month = 3 - a.day = [1, 2] - a.hour = (10..20) - a.minute = (10..20).step(2) - a.second = true - a.millisecond = ->(_val : Int32) { true } - end - - it "can materialize" do - vt = VirtualTime.new - # year, month, day, week, day_of_week, day_of_year, hour, minute, second, millisecond, nanosecond, location - vt.materialize(Time::UNIX_EPOCH).to_tuple.should eq({1970, 1, 1, nil, nil, nil, 0, 0, 0, nil, 0, nil}) - end - - it "can expand VTs" do - d = VirtualTime.new - d.year = 2017 - # d.month= 1..3 - d.day = 14..17 - d.hour = 9..12 - d.millisecond = 1 - d.expand.should eq [ - VirtualTime.new(2017, nil, 14, nil, nil, nil, 9, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 14, nil, nil, nil, 10, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 14, nil, nil, nil, 11, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 14, nil, nil, nil, 12, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 15, nil, nil, nil, 9, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 15, nil, nil, nil, 10, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 15, nil, nil, nil, 11, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 15, nil, nil, nil, 12, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 16, nil, nil, nil, 9, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 16, nil, nil, nil, 10, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 16, nil, nil, nil, 11, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 16, nil, nil, nil, 12, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 17, nil, nil, nil, 9, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 17, nil, nil, nil, 10, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 17, nil, nil, nil, 11, nil, nil, 1, nil, nil), - VirtualTime.new(2017, nil, 17, nil, nil, nil, 12, nil, nil, 1, nil, nil), - ] - end - - it "can match Crystal's Times" do - vt = VirtualTime.new - - vt.matches?(Time.local).should be_true - - vt.month = 3 - vt.day = (10..20).step(2) - - vt.matches?(Time.parse("2018-03-10", "%F", Time::Location::UTC)).should be_true - vt.matches?(Time.parse("2018-03-11", "%F", Time::Location::UTC)).should be_nil - end - - it "can #to_yaml and #from_yaml" do - date = VirtualTime.new - date.year = 2017 - date.month = 4..6 - date.day = true - date.hour = (2..8).step 3 - - y = date.to_yaml - date2 = VirtualTime.from_yaml y - y.should eq date2.to_yaml - - date.hour = (2...10).step 3 - - y = date.to_yaml - date2 = VirtualTime.from_yaml y - y.should eq date2.to_yaml - end - - it "converts to YAML" do - vt = VirtualTime.new - vt.month = 3 - vt.day = [1, 2] - vt.hour = (10..20) - vt.minute = (10..20).step 2 - vt.second = true - # vt.millisecond = ->( val : Int32) { true } - vt.to_yaml.should eq "---\nmonth: 3\nday: 1,2\nhour: 10..20\nminute: 10,12,14,16,18,20\nsecond: true\n" - end - it "converts from YAML" do - vt = VirtualTime.from_yaml "---\nmonth: 3\nday: 1,2\nhour: 10..20\nminute: 10,12,14,16,18,20\nsecond: true\n" - vt.month.should eq 3 - vt.day.should eq [1, 2] - vt.hour.should eq 10..20 - end -end diff --git a/src/virtualdate.cr b/src/virtualdate.cr new file mode 100644 index 0000000..7a18869 --- /dev/null +++ b/src/virtualdate.cr @@ -0,0 +1,212 @@ +require "virtualtime" + +class VirtualDate + VERSION_MAJOR = 1 + VERSION_MINOR = 0 + VERSION_REVISION = 0 + VERSION = [VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION].join '.' + + # alias TimeOrVirtualTime = ::Time | VirtualTime + + # Fixed value of `#on?` for this item. This is useful for outright setting the item's status, without any calculations. + # + # It can be used for things such as: + # - Marking an item as parmanently on, e.g. after it has once been activated + # - Marking an item as permanently off, if it was disabled until further notice + # - Marking the item as always shifted/postponed by certain time (e.g. to keep it on the 'upcoming' list or something) + # + # This field has the same union of types as `#shift`. + # + # The default is nil (no setting), to not override anything and allow for the standard calculations to run. + # If defined, this setting takes precedence over `#start` and `#stop`. + property on : Nil | Bool | Time::Span + + # Absolute start date/time. Item is never "on" before this date. + property start : Time? + + # Absolute stop date/time. Item is never "on" after this date. + property stop : Time? + + # List of VirtualTimes on which the item is "on"/due/active. + property due = [] of VirtualTime + + # List of VirtualTimes on which the item should be "omitted", i.e. on which it should not be on/due/active. + # For example, this list may include weekends, known holidays in a year, sick days, vacation days, etc. + # + # Maybe this list should also implicitly contain all already scheduled items? + property omit = [] of VirtualTime + + # Decision about an item to make if it falls on an omitted date/time. + # + # Allowed values are: + # - nil: treat the item as non-applicable/not-scheduled on the specified date/time + # - false: treat the item as not due due to falling on an omitted date/time, after a reschedule was not attempted or was not able to find another spot + # - true: treat the item as due regardless of falling on an omitted date/time + # - Time::Span: shift the scheduled date/time by specified time span. Can be negative (for rescheduling before the original due) or positive (for rescheduling after the original due)). + # + # If a time span is specified, shifting is performed incrementaly until a suitable date/time is found, or until max number of shift attempts is reached. + property shift : Nil | Bool | Time::Span = false + + # Max amount of total time by which item can be shifted, before it's considered unschedulable (false) + # E.g., if a company has meetings every 7 days, it probably makes no sense to reschedule a particular meeting for more than 6 days later, since on the 7th day a new meeting would be scheduled anyway. + property max_shift : Time::Span? + + # Max amount of shift attempts, before it's considered unschedulable (false) + # + # If `shift = 1.minute` and `max_shifts = 1440`, it means the item will be shifted at most + # 1440 minutes (1 day) compared to the original time for which it was asked, and on which it was + # unschedulable due to omit times. + property max_shifts = 1500 + + # TODO: + # Add properties for: + # 1. Duration of item (how long something will take, e.g. a meeting) + # 2. Concurrency of item (how many items can be scheduled with this one concurrently) + + # Checks whether the item is "on" on the specified date/time. Item is + # considered "on" if it matches at least one "due" time and does not + # match any "omit" time. If it matches an omit time, then depending on + # the value of shift it may still be "on", or attempted to be + # rescheduled. Return values are: + # nil - item is not "on" / not "due" + # true - item is "on" (it is "due" and not on "omit" list) + # false - item is due, but that date is omitted, and no reschedule was requested or possible, so effectively it is not "on" + # Time::Span - span which is to be added to asked date to reach the earliest/closest time when item is "on" + def on?(time = Time.local, *, max_shift = @max_shift, max_shifts = @max_shifts) + # If `@on` is non-nil, it will dictate the item's status. + @on.try { |status| return status } + + # If date asked is not within item's absolute start-stop time, consider it not scheduled + a, z = @start, @stop + return if a && (a > time) + return if z && (z < time) + + # Otherwise, we go perform the calculation: + yes = due_on? time + no = omit_on? time + + if yes + if !no + true + else # Item falls on omitted time, try rescheduling + shift = @shift + if shift.is_a? Nil | Bool + shift + elsif shift.total_nanoseconds == 0 + false + else + # +amount => search into the future, -amount => search into the past + new_time = time.dup + + shifts = 0 + ret = loop do + shifts += 1 + new_time += shift + + if (max_shift && ((new_time - time).total_nanoseconds.abs > max_shift.total_nanoseconds)) || (max_shifts && (shifts > max_shifts)) + break false + end + if omit_on? new_time + next + else + break true + end + + if shifts >= max_shifts + break false + end + end + + return ret ? (new_time - time) : ret + end + end + end + end + + # Due Date/Time-related functions + + # Checks if item is due on any of its date and time specifications. + def due_on?(time = Time.local, times = @due) + due_on_any_date?(time, times) && due_on_any_time?(time, times) + end + + # Checks if item is due on any of its date specifications (without times). + def due_on_any_date?(time = Time.local, times = @due) + times = virtual_dates times + matches_any_date?(time, times, true) + end + + # Checks if item is due on any of its time specifications (without dates). + def due_on_any_time?(time = Time.local, times = @due) + times = virtual_dates times + matches_any_time?(time, times, true) + end + + # Omit Date/Time-related functions + + # Checks if item is omitted on any of its date and time specifications. + def omit_on?(time = Time.local, times = @omit) + omit_on_dates?(time, times) && omit_on_times?(time, times) + end + + # Checks if item is omitted on any of its date specifications (without times). + def omit_on_dates?(time = Time.local, times = @omit) + times = virtual_dates times + matches_any_date?(time, times, nil) + end + + # Checks if item is omitted on any of its time specifications (without dates). + def omit_on_times?(time = Time.local, times = @omit) + times = virtual_dates times + matches_any_time?(time, times, nil) + end + + # Helper methods below, used by both due- and omit-related functions. + + # Checks if any item in `times` matches the date part of `time` + def matches_any_date?(time : Time, times, default) + return default if !times || (times.size == 0) + + times.each do |vt| + return true if vt.matches_date? time + end + + nil + end + + # Checks if any item in `times` matches the time part of `time` + def matches_any_time?(time, times, default) + return default if !times || (times.size == 0) + + times.each do |e| + return true if e.matches_time? time + end + + nil + end + + # Replaces any values of 'true' with a list of default VTs. By default, the list is emtpy. + # + # NOTE: This implementation should be replaced with an iterator + def virtual_dates(list, default_list = [] of VirtualTime) # TimeOrVirtualTime) + list = force_array list + di = list.index(true) + if di + list = list.dup + list[di..di] = default_list + end + list + end + + # Wraps object in an Array if it is not an Array already. + def force_array(arg) + if !arg.is_a? Array + [arg] + else + arg + end + end + + class Reminder < VirtualDate + end +end diff --git a/src/virtualtime.cr b/src/virtualtime.cr deleted file mode 100644 index 84ca8e3..0000000 --- a/src/virtualtime.cr +++ /dev/null @@ -1,477 +0,0 @@ -require "yaml" - -class VirtualTime - VERSION_MAJOR = 1 - VERSION_MINOR = 0 - VERSION_REVISION = 0 - VERSION = [VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION].join '.' - - include Comparable(self) - include Comparable(Time) - include YAML::Serializable - - # TODO Use Int instead of Int32 when it becomes possible in unions in Crystal - # Separately, XXX, https://github.com/crystal-lang/crystal/issues/14047, when it gets solved, add Enumerable(Int32) and remove Array/Steppable - alias Virtual = Nil | Int32 | Bool | Range(Int32, Int32) | Proc(Int32, Bool) | Array(Int32) | Steppable::StepIterator(Int32, Int32, Int32) - - # VirtualTime Tuple alias - alias VTTuple = Tuple(Virtual, Virtual, Virtual, Virtual, Virtual, Virtual, Virtual, Virtual, Virtual, Virtual, Virtual, Time::Location?) - - # Date-related properties - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property year : Virtual # 1 - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property month : Virtual # 1 - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property day : Virtual # 1 - - # Higher-level date-related properties - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property week : Virtual # 1 - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property day_of_week : Virtual # 1 - Monday - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property day_of_year : Virtual # 1 - - # Time-related properties - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property hour : Virtual # 0 - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property minute : Virtual # 0 - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property second : Virtual # 0 - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property millisecond : Virtual # 0 - @[YAML::Field(converter: VirtualTime::VirtualTimeConverter)] - property nanosecond : Virtual # 0 - - # Location/timezone in which to perform matching, if any - @[YAML::Field(converter: VirtualTime::TimeLocationConverter)] - property location : Time::Location? - - def initialize(@year = nil, @month = nil, @day = nil, @hour = nil, @minute = nil, @second = nil, *, @millisecond = nil, @nanosecond = nil, @day_of_week = nil, @day_of_year = nil, @week = nil) - end - - def initialize(*, @year, @week, @day_of_week = nil, @hour = nil, @minute = nil, @second = nil, @millisecond = nil, @nanosecond = nil) - end - - def initialize(@year, @month, @day, @week, @day_of_week, @day_of_year, @hour, @minute, @second, @millisecond, @nanosecond, @location) - end - - # Matching - - # :nodoc: - macro adjust_location - if (l = location) && (time.location != l) - time = time.in l - end - end - - # Returns whether `VirtualTime` matches the specified time - def matches?(time = Time.local) - adjust_location - matches_date?(time) && matches_time?(time) - end - - # Returns whether `VirtualTime` matches the date part of specified time - def matches_date?(time = Time.local) - adjust_location - self.class.matches?(year, time.year, 9999) && - self.class.matches?(month, time.month, 12) && - self.class.matches?(day, time.day, TimeHelper.days_in_month(time)) && - self.class.matches?(week, time.calendar_week[1].to_i, TimeHelper.weeks_in_year(time)) && - self.class.matches?(day_of_week, time.day_of_week.to_i, 7) && - self.class.matches?(day_of_year, time.day_of_year, TimeHelper.days_in_year(time)) - end - - # Returns whether `VirtualTime` matches the time part of specified time - def matches_time?(time = Time.local) - adjust_location - self.class.matches?(hour, time.hour, 23) && - self.class.matches?(minute, time.minute, 59) && - self.class.matches?(second, time.second, 59) && - self.class.matches?(millisecond, time.millisecond, 999) && - self.class.matches?(nanosecond, time.nanosecond, 999_999_999) - end - - # Performs matching between VirtualTime type and any other type, usually Ints coming from Time - def self.matches?(a : Nil, b, max = nil) - true - end - - # :ditto: - def self.matches?(a : Bool, b, max = nil) - a - end - - # :ditto: - def self.matches?(a : Int, b : Int, max = nil) - a = max + a + 1 if max && (a < 0) - (a == b) || nil - end - - # :ditto: - def self.matches?(a : Range(Int, Int), b, max = nil) - if max && (a.begin < 0 || a.end < 0) - ab = a.begin < 0 ? max + a.begin + 1 : a.begin - ae = a.end < 0 ? max + a.end + 1 : a.end - a = ab..ae - end - a.includes?(b) || nil - end - - # :ditto: - def self.matches?(a : Enumerable(Int), b, max = nil) - # XXX What to do about having to dup the enumerable/iterator not to rewind it? - aa = a.dup - if max # && a.any?(&.<(0)) - aa = a.map { |e| e < 0 ? max + e + 1 : e } - end - aa.includes?(b) || nil - end - - # :ditto: - def self.matches?(a : Proc(Int32, Bool), b, max = nil) - a.call b - end - - # Materializing - # Time: year, month, day, calendar_week, day_of_week, day_of_year, hour, minute, second, millisecond, nanosecond, location - - # Returns a new, "materialized" VirtualTime. I.e., it converts VirtualTime object to a Time-like value, where all fields have "materialized"/specific values - def materialize(hint = Time.local, strict = true) - # TODO Possibly default the hint to now + 1 minute, with second/nanosecond values set to 0 - self.class.new **materialize_with_hint(hint) - end - - # :nodoc: - def materialize_with_hint(time : Time) - { - year: self.class.materialize(year, time.year, 9999), - month: self.class.materialize(month, time.month, 12), - day: self.class.materialize(day, time.day, TimeHelper.days_in_month(time)), - hour: self.class.materialize(hour, time.hour, 23), - minute: self.class.materialize(minute, time.minute, 59), - second: self.class.materialize(second, time.second, 59), - nanosecond: self.class.materialize(nanosecond, time.nanosecond, 999_999_999), - } - end - - # Materialize a particular value with the help of a hint/default value. - # If 'strict' is true and default value does not satisfy predefined range or requirements, the default is replaced with the first/earliest value from allowed range. - def self.materialize(value : Nil, default : Int32, max = nil, strict = true) - default - end - - # :ditto: - def self.materialize(value : Bool, default : Int32, max = nil, strict = true) - default - end - - # :ditto: - def self.materialize(value : Int, default : Int32, max = nil, strict = true) - max && (value < 0) ? max + value + 1 : value - end - - # :ditto: - def self.materialize(value : Enumerable(Int), default : Int32, max = nil, strict = true) - if max && value.any?(&.<(0)) - value = value.map { |e| e < 0 ? max + e + 1 : e } - end - if !strict || value.includes? default - default - else - value.min - end - end - - # :ditto: - def self.materialize(value : Range(Int, Int), default : Int32, max = nil, strict = true) - if max && (value.begin < 0 || value.end < 0) - ab = value.begin < 0 ? max + value.begin + 1 : value.begin - ae = value.end < 0 ? max + value.end + 1 : value.end - value = ab..ae - end - - if !strict || value.includes? default - default - else - value.begin - end - end - - # :ditto: - def self.materialize(value : Proc(Int32, Bool), default : Int32, max = nil, strict = true) - default - end - - # Comparison with self - - def <=>(other : self) - (year == other.year) && - (month == other.month) && - (day == other.day) && - (week == other.week) && - (day_of_week == other.day_of_week) && - (day_of_year == other.day_of_year) && - (hour == other.hour) && - (minute == other.minute) && - (second == other.second) && - (millisecond == other.millisecond) && - (nanosecond == other.nanosecond) && 0 - end - - # Comparison and conversion to and from time - - # Compares `VirtualTime` to `Time` instance - def <=>(other : Time) - to_time <=> other - end - - # "Rewinds" `day` forward enough to reach `acceptable_day`. - # - # It wraps around `wrap_day`, so e.g. `adjust_day(25, 5, 30)` returns `10.days` - def adjust_day(day : Int, acceptable_day : Int, wrap_day : Int) - amount = 0 - - if day != acceptable_day - if acceptable_day > day - amount = (acceptable_day - day) - else - amount = (wrap_day - day) + acceptable_day - end - end - - amount.days - end - - # Converts a VirtualTime to a specific Time object that hopefully matches the VirtualTime. - # - # Value is converted using a time hint, which defaults to the current time. - # Lists and ranges of values materialize to their min / begin value. - # - # Additionally, any requirements for week number, day of week, and day of year are also met, - # possibly by doing multiple iterations to find a suitable date. The process is limited to - # 10 attempts of trying to find a value that simultaneously satisfies all constraints. - def to_time(hint = Time.local, strict = true) - # TODO Possibly default the hint to now + 1 minute, with second/nanosecond values set to 0 - time = Time.local **materialize_with_hint(hint), location: hint.location - - max_tries = 10 - tries = 0 - loop do - tries += 1 - - week_nr = time.calendar_week[1] - time += adjust_day(week_nr, self.class.materialize(week, week_nr, TimeHelper.weeks_in_year(time), strict), TimeHelper.weeks_in_year(time)) * 7 - - day = time.day_of_week.to_i - time += adjust_day(day, self.class.materialize(day_of_week, day, 7, strict), 7) - - day = time.day_of_year - time += adjust_day(day, self.class.materialize(day_of_year, day, TimeHelper.days_in_year(time), strict), TimeHelper.days_in_year(time)) - - if matches_date?(time) - break - else - if tries >= max_tries - # TODO maybe some other error, not arg err - raise ArgumentError.new "Could not find a date that satisfies week number, day of week, and day of year after #{max_tries} iterations" - end - end - end - - time - end - - # Creates `VirtualTime` from `Time`. - # This can be useful to produce a VT with many fields filled in quickly, and then set fields of choice to more interesting values rather than fixed integers. - # - # Note that this copies all values from `Time` to `VirtualTime`, including week number, day of week, day of year. - # That results in a very fixed `VirtualTime` which is probably not useful unless some values are afterwards reset to nil or set to other VT-specific options. - # - # Millisecond and nanosecond values are copied from `Time` into `VirtualTime` only if options `milliseconds=` and `nanoseconds=` are set to true. - # Default is currently true for nanoseconds. - # Whether these options are useful, or whether they should be removed, or whether all fields should get a corresponding option like this, remains be seen. - def self.from_time(time : Time, *, milliseconds = false, nanoseconds = true) - new \ - year: time.year, - month: time.month, - day: time.day, - week: time.calendar_week[1], - day_of_week: time.day_of_week.to_i, - day_of_year: time.day_of_year, - hour: time.hour, - minute: time.minute, - second: time.second, - millisecond: milliseconds ? time.millisecond : nil, - nanosecond: nanoseconds ? time.nanosecond : nil - end - - # Misc conversions - - # Outputs VirtualTime instance as a tuple with signature `Tuple(11x Virtual, Time::Location?)` - def to_tuple - VTTuple.new year, month, day, week, day_of_week, day_of_year, hour, minute, second, millisecond, nanosecond, location - end - - # Expands VirtualTime containing ranges or lists into a list of individual VirtualTimes with specific values - # E.g. VirtualTime with `day=1..2` gets expanded into two separate VirtualTimes, day=1 and day=2 - def expand - ArrayHelper.expand(VTTuple.new year, month, day, week, day_of_week, day_of_year, hour, minute, second, millisecond, nanosecond, location).map { |v| self.class.new *(VTTuple.from v) } - end - - # Helper methods below - - module TimeHelper - # Returns number of weeks in a year. - # It is calculated as number of Mondays in the year up to the ordinal date. - # - # Thus it is possible for this function to return value of `53` (53th week in a year) for up to 4 last days in the current year. - # That is, for Dec 28-31. An example of such year was 2020. - # - # In other words, value `53` will be seen if January 1 of next year is on a Friday, or the year was a leap year. - # - # The calculation is identical as the first part of `Time#calendar_week`. - # . - @[AlwaysInline] - def self.weeks_in_year(time) - (time.at_end_of_year.day_of_year - time.day_of_week.to_i + 10) // 7 - end - - # Returns current week of year. - # - # This function returns a value in range 0..53. - # - # Up to first 3 days of a year (Jan 1-3) may return value 0. This means they are in the new year, but technically they belong to a week that started on Monday in the previous year. - # Week number 53 means January 1 is on a Friday, or the year was a leap year. - # - # The calculation is identical as the first part of `Time#calendar_week`. - @[AlwaysInline] - def self.week_of_year(time) - (time.day_of_year - time.day_of_week.to_i + 10) // 7 - end - - # Returns number of days in current month - @[AlwaysInline] - def self.days_in_month(time) - Time.days_in_month time.year, time.month - end - - # Returns number of days in current year - @[AlwaysInline] - def self.days_in_year(time) - Time.days_in_year time.year - end - end - - module ArrayHelper - # Expands ranges and other expandable types into a long list of all possible options. - # E.g. [1, 2..3, 4..5] gets expanded into [[1, 2, 4], [1,2, 5], [1,3,4], [1,3,5]]. - def self.expand(list) - Indexable.cartesian_product list.map { |e| - case e - when Array - e - when Enumerable - e.dup.to_a - else - [e] - end - } - end - end - - # A custom to/from YAML converter for VirtualTime. - class VirtualTimeConverter - def self.to_yaml(value : VirtualTime::Virtual, yaml : YAML::Nodes::Builder) - case value - # when Nil - # # Nils are ignored; they default to nil in constructor if/when a value is missing - # yaml.scalar "nil" - when Int - yaml.scalar value - when Bool - yaml.scalar value - # This case wont match - when Range(Int32, Int32) - # TODO seems there is no support for range with step? - yaml.scalar value # .begin.to_s+ ".."+ (value.exclusive? ? value.end- 1 : value.end).to_s - when Array(Int32) - yaml.scalar value.join "," - when Enumerable - # The IF is here to workaround a bug in Crystal <= 0.23: - # https://github.com/crystal-lang/crystal/issues/4684 - # if value.class == Range(Int32, Int32) - # value = value.unsafe_as Range(Int32, Int32) - # yaml.scalar value # .begin.to_s+ ".."+ (value.exclusive? ? value.end- 1 : value.end).to_s - # else - # Done in this way because in Crystal <= 0.23 there is - # no way to detect a step once it's set: - # https://github.com/crystal-lang/crystal/issues/4695 - yaml.scalar value.join "," - # end - else - raise "Cannot convert #{value.class} to YAML" - end - end - - def self.from_yaml(value : String | IO) : VirtualTime::Virtual - parse_from value - end - - def self.from_yaml(value : YAML::ParseContext, node : YAML::Nodes::Node) : VirtualTime::Virtual - unless node.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{node.class}" - end - parse_from node.value - end - - @[AlwaysInline] - def self.parse_from(value) - case value - when "nil" - nil - when /^\d+$/ - value.to_i - when /^(\d+,?)+$/ - value.split(",").map &.to_i - when /^(\d+)\.\.\.(\d+)(?:\/(\d+))$/ - ($1.to_i...$2.to_i).step($3.to_i) - when /^(\d+)\.\.\.(\d+)$/ - $1.to_i...$2.to_i - when /^(\d+)\.\.(\d+)(?:\/(\d+))$/ - ($1.to_i..$2.to_i).step($3.to_i) - when /^(\d+)\.\.(\d+)$/ - $1.to_i..$2.to_i - when "true" - true - when "false" - false - # XXX The next one is here just to satisfy return type. It doesn't really work. - when /^->/ - ->(_v : Int32) { true } - else - raise ArgumentError.new "Invalid YAML input (#{value})" - end - end - end - - # A custom to/from YAML converter for Time::Location. - class TimeLocationConverter - def self.to_yaml(value : Time::Location, yaml : YAML::Nodes::Builder) - value.name - end - - def self.from_yaml(value : String | IO) : Time::Location - Time::Location.load value - end - - def self.from_yaml(value : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Location - unless node.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{node.class}" - end - Time::Location.load node.value - end - end -end