Skip to content

Commit

Permalink
Code redesign for VirtualDate
Browse files Browse the repository at this point in the history
  • Loading branch information
docelic committed Dec 11, 2023
1 parent 2207653 commit e132004
Show file tree
Hide file tree
Showing 8 changed files with 358 additions and 709 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/doc/
/docs/
/lib/
/bin/
/.shards/
Expand Down
258 changes: 133 additions & 125 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,158 +1,166 @@
[![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

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"]) # ==> #<Time::Span @span=2.00:00:00>
# 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

Expand Down
9 changes: 7 additions & 2 deletions shard.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
name: virtualtime
name: virtualdate
version: 1.0.0

authors:
- Davor Ocelic <docelic@crystallabs.io>

crystal: 0.35.1

license: GPL-3.0
license: AGPL-3.0

dependencies:
virtualtime:
github: crystallabs/virtualtime
version: ~> 1.0
2 changes: 0 additions & 2 deletions spec/spec_helper.cr

This file was deleted.

5 changes: 5 additions & 0 deletions spec/virtualdate_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
require "spec"
require "../src/virtualdate"

describe VirtualTime do
end
Loading

0 comments on commit e132004

Please sign in to comment.