Skip to content

Commit

Permalink
Add ability to check #on? on VirtualTimes; add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
docelic committed Dec 20, 2023
1 parent 4d5f488 commit d0e9fb4
Show file tree
Hide file tree
Showing 3 changed files with 186 additions and 62 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
[![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)

VirtualDate is a time scheduling component for Crystal. It is a sibling project of [virtualtime](https://github.com/crystallabs/virtualtime).
VirtualDate is a time scheduling component for Crystal. It is a companion project to [virtualtime](https://github.com/crystallabs/virtualtime).
It is used for complex and flexible, and often recurring, time/event scheduling.

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.
This shard VirtualDate implements the high-level part, the actual items one might want to schedule.

# Installation

Expand Down Expand Up @@ -44,7 +44,7 @@ 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:
Thus, altogether `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
Expand All @@ -65,7 +65,7 @@ and to simply treat it as not scheduled on a particular date. A `Boolean` explic
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.

If there are multiple `VirtualDate`s set for a field, e.g. for `due` date, the matches are logically OR-ed;
If there are multiple `VirtualTime`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.

# Usage
Expand Down
147 changes: 147 additions & 0 deletions spec/virtualdate_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,153 @@ describe VirtualDate do
vd.on?(date).should eq -15.minutes
end

it "can match virtual dates" do
item = VirtualDate.new

vt = VirtualTime.new year: 2017, month: 3, day: 15
item.due << vt

date = vt.dup
item.due_on_any_date?(date).should be_true
date.year = nil
date.month = nil
date.day = nil
item.due_on_any_date?(date).should be_true
date.month = 3
item.due_on_any_date?(date).should be_true
date.month = 4
item.due_on_any_date?(date).should be_nil

date = VirtualTime.new
date.month = nil
date.day = 15
item.due_on_any_date?(date).should be_true
date.day = 1
item.due_on_any_date?(date).should be_nil
date.day = 13..18
item.due_on_any_date?(date).should be_true
vt.day = 10..20
item.due_on_any_date?(date).should be_true
vt.day = 15
date.day = 15
item.due_on_any_date?(date).should be_true
date.day = nil
item.due_on_any_date?(date).should be_true
date.month = 2
item.due_on_any_date?(date).should be_nil
date.month = 3
item.due_on_any_date?(date).should be_true
date.day = 13..18
item.due_on_any_date?(date).should be_true

vt2 = VirtualTime.new
vt2.month = 3
item.due = [vt2]
date = VirtualTime.new
date.day = 13..18
item.due_on_any_date?(date).should be_true
date.month = 2
item.due_on_any_date?(date).should be_nil
date.month = 2..4
item.due_on_any_date?(date).should be_true
date.month = nil
vt2.month = nil
vt2.day = 15..18
date.day = 15..18
item.due_on_any_date?(date).should be_true
date.day = 15..19
item.due_on_any_date?(date).should be_true
end

it "can shift on simple rules" do
item = VirtualDate.new
due = VirtualTime.new year: 2017, month: 3, day: 15
date = VirtualTime.new year: 2017, month: 3, day: 15
omit = VirtualTime.new year: 2017, month: 3, day: 15
omit2 = VirtualTime.new year: 2017, month: 3, day: 14
shift = -1.day

item.due = [due]
item.on?(date).should be_true
item.omit = [omit]
item.on?(date).should be_false
item.shift = shift

item.on?(date).should eq -1.day
item.omit << omit2
item.on?(date).should eq -2.days

item = VirtualDate.new
due = VirtualTime.new year: 2017, month: 3, day: 15, hour: 1, minute: 34, second: 0
date = VirtualTime.new year: 2017, month: 3, day: 15, hour: 1, minute: 34, second: 0
item.shift = 3.minutes
omit = VirtualTime.new
omit.hour = 1
item.due = [due]
item.omit = [omit]
item.on?(date).should eq 27.minutes
end

it "can shift on complex rules" do
item = VirtualDate.new
due = VirtualTime.new
due.day = 4
date = VirtualTime.new
date.day = 4
item.shift = Time::Span.new days: 7, hours: 10, minutes: 20, seconds: 30
omit = VirtualTime.new
omit.day = 4
item.due = [due]
item.omit = [omit]
item.on?(date).should eq Time::Span.new days: 7, hours: 10, minutes: 20, seconds: 30

item = VirtualDate.new
due = VirtualTime.new
due.day = 4
date = VirtualTime.new
date.day = 4
item.shift = Time::Span.new days: 7, hours: 10, minutes: 20, seconds: 30
omit = VirtualTime.new
omit.day = 3..14
item.due = [due]
item.omit = [omit]
item.on?(date).should eq Time::Span.new days: 14, hours: 20, minutes: 41, seconds: 0

item = VirtualDate.new
tl = Time.local.at_beginning_of_month
item.due = [VirtualTime.new day: tl.day]
item.omit = [VirtualTime.new(day: tl.day..((tl + 9.days).day))]
item.shift = Time::Span.new days: 7, hours: 10, minutes: 20, seconds: 30
date = VirtualTime.from_time tl.at_beginning_of_day
item.on?(date).should eq Time::Span.new days: 14, hours: 20, minutes: 41, seconds: 0
end

it "can check due_on_any_dates with ranges" do
item = VirtualDate.new
due = VirtualTime.new
due.day = 4..12
# item.shift= VirtualTime::Span.new 7,10,20,30
omit = VirtualTime.new
omit.day = 12
item.due = [due]
item.omit = [omit]

date = VirtualTime.new
date.day = 8..11
# puts date.inspect

item.on?(date).should be_true

date.day = 8..14

dates = date.expand
r = dates.map { |d| item.on? d }
r.should eq [true, true, true, true, false, nil, nil]

# And another form of saying it:
dates.map { |d| item.on? d }.any? { |x| x }.should be_true
end

it "can shift til !due_on?( @omit) && due_on?( @due)" do
vd = VirtualDate.new
vd.shift = 1.day
Expand Down
93 changes: 35 additions & 58 deletions src/virtualdate.cr
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,13 @@ class VirtualDate
VERSION_REVISION = 0
VERSION = [VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION].join '.'

# alias TimeOrVirtualTime = ::Time | VirtualTime
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 begin date/time. Item is never "on" before this date.
property begin : Time?

# Absolute stop date/time. Item is never "on" after this date.
property stop : Time?
# Absolute end date/time. Item is never "on" after this date.
property end : Time?

# List of VirtualTimes on which the item is "on"/due/active.
property due = [] of VirtualTime
Expand Down Expand Up @@ -58,6 +45,19 @@ class VirtualDate
# unschedulable due to omit times.
property max_shifts = 1500

# 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 `#begin` and `#end`.
property on : Nil | Bool | Time::Span

# TODO:
# Add properties for:
# 1. Duration of item (how long something will take, e.g. a meeting)
Expand All @@ -72,12 +72,18 @@ class VirtualDate
# 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)
def on?(time : TimeOrVirtualTime = Time.local, *, max_shift = @max_shift, max_shifts = @max_shifts, hint = time.is_a?(Time) ? time : Time.local)
# 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
# VirtualTimes do not have a <=> relation. They inevitably must be converted to a `Time` before such comparisons.
# Even a time hint is supported, in case you are checking for some date in the future.
if time.is_a? VirtualTime
time = time.to_time hint
end

# If date asked is not within item's absolute begin-end time, consider it not scheduled
a, z = @begin, @end
return if a && (a > time)
return if z && (z < time)

Expand Down Expand Up @@ -126,45 +132,41 @@ class VirtualDate
# 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)
def due_on?(time : TimeOrVirtualTime = 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
def due_on_any_date?(time : TimeOrVirtualTime = Time.local, times = @due)
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
def due_on_any_time?(time : TimeOrVirtualTime = Time.local, times = @due)
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)
def omit_on?(time : TimeOrVirtualTime = 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
def omit_on_dates?(time : TimeOrVirtualTime = Time.local, times = @omit)
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
def omit_on_times?(time : TimeOrVirtualTime = Time.local, times = @omit)
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)
def matches_any_date?(time : TimeOrVirtualTime, times, default)
return default if !times || (times.size == 0)

times.each do |vt|
Expand All @@ -175,7 +177,7 @@ class VirtualDate
end

# Checks if any item in `times` matches the time part of `time`
def matches_any_time?(time, times, default)
def matches_any_time?(time : TimeOrVirtualTime, times, default)
return default if !times || (times.size == 0)

times.each do |e|
Expand All @@ -184,29 +186,4 @@ class VirtualDate

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

0 comments on commit d0e9fb4

Please sign in to comment.