Linux CI Version License

Installation

Add the following to your application's "shard.yml":

dependencies:
  virtualtime:
    github: crystallabs/virtualtime
    version: ~> 1.0

And run shards install or just shards.

Introduction

VirtualTime is a Time-related class for Crystal. It is used for matching and generation of compliant dates and times, primarily for calendar, scheduling, and reminding purposes.

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 in VirtualTime's terminology).

On the other hand, VirtualTimes do not have to represent any specific points in time (although they can be defined precisely enough (or converted) so that they do). They are intended for conveniently matching broader sets of values and for finding/generating times that match certain constraints.

1. Matching Times

You can express date and time constraints in the VirtualTime object and then match various Times against it to determine which ones match.

For example, let's create a VirtualTime that matches the last Saturday or Sunday of every month. This can be expressed using two constraints:

Then we can check if the current Time matches it:

vt = VirtualTime.new
vt.day = -8..-1
vt.day_of_week = [6,7]

# Check if current time matches
vt.matches?(Time.local) # => result depends on current time

2. Matching VirtualTimes

In addition to matching Times, it is also possible to match VirtualTimes against each other.

Let's say we are interested in knowing whether the above VT would match any day in the month of March.

We could do this with:

# Same VT as before:
vt = VirtualTime.new
vt.day = -8..-1
vt.day_of_week = [6,7]

# Check if the specified VT matches any day in month of March
any_in_march = VirtualTime.new month: 3
vt.matches?(any_in_march) # => true

Note that #matches? is commutative and it could have also been written as any_in_march.matches?(vt).

3. Generating Times

In addition to matching, it is also possible to successively generate Times that match specified VirtualTime constraints.

For example, let's create a VirtualTime that matches the last weekend days of every month and print a list of next 10 such dates.

Note: This can be expressed as:

vt = VirtualTime.new
vt.year = 2020..2030
vt.day = -7..-1
vt.day_of_week = [6,7]

But it would match cases like Sunday 25th and Saturday 31st. If that matches your requirements nothing else needs to be done. But if you want to find dates of the last full weekends in every month, use a slightly dfferent rule that matches Saturday in the last week of the month, with at least one more day in the month remaining:

vt = VirtualTime.new
vt.year = 2020..2030
vt.day = -8..-2
vt.day_of_week = 6

Then we can print 10 such Saturdays, starting from the earliest date satisfiable by our constraints:

vt = VirtualTime.new
vt.year = 2020..2030
vt.day = -8..-2
vt.day_of_week = 6

vti = vt.step(interval = 1.day, by = 1, from: Time.unix(0))

10.times do
  p vti.next
end

2020-01-25 00:00:00.000000001+01:00Z
2020-02-22 00:00:00.000000001+01:00Z
2020-03-28 00:00:00.000000001+01:00Z
2020-04-25 01:00:00.000000001+02:00Z
2020-05-30 01:00:00.000000001+02:00Z
2020-06-27 01:00:00.000000001+02:00Z
2020-07-25 01:00:00.000000001+02:00Z
2020-08-29 01:00:00.000000001+02:00Z
2020-09-26 01:00:00.000000001+02:00Z
2020-10-24 01:00:00.000000001+02:00Z

Now let's take a look at implementation details.

Supported Property Values

All VirtualTime instances contain the following properties:

  1. Year (0..9999)
  2. Month (1..12)
  3. Day (1..31)
  4. Week number of year (0..53)
  5. Day of week (1..7, Monday == 1)
  6. Day of year (1..366)
  7. Hour (0..23)
  8. Minute (0..59)
  9. Second (0..59)
  10. Millisecond (0..999)
  11. Nanosecond (0..999_999_999)

And each of these properties can have a value of the following types:

  1. Nil, to default matches to VirtualTime.default_match? : Bool = true
  2. Boolean, to always match (true) or fail (false)
  3. Int32, to match a specific value such as 5, 12, 2023, -1, or -5
  4. Array or Set of Int32s, such as [1,2,10,-1] to match any value in list
  5. Range of Int32..Int32, such as 10..-1 to match any value in range
  6. Range with step, e.g. day: (10..20).step(2), to match any value in range with step
  7. 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. Properties that are nil will match depending on the value of #default_match?.

Knowing the structure of VirtualTime now, let's create a more elaborate example, with descriptions included inline:

vt = VirtualTime.new
vt.month = 3                # Month of March
vt.day = [1,-1]             # First and last day of every month
vt.hour = (10..20)          # Hour between 10 and 20, inclusively
vt.minute = (0..59).step(2) # Every other (even) minute in an hour
vt.second = true            # Unconditional match
vt.millisecond = ->( val : Int32) { true } # Unconditional match, since block returns true
vt.location = Time::Location.load("Europe/Amsterdam")

vt.matches?(Time.local) # => result depends on current time

As can be seen above, fields can have some interesting values, such as negative numbers.

Here is a more detailed list of all non-obvious values that are supported:

Negative Integer Values

Negative integer values count from the end of the range, if the max / wrap-around value is specified. 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.

If the wrap-around value is not specified, negative values are not converted to positive ones, and they enter matching as-is. In practice, this means they will not match any Times, but may match similar VirtualTimes.

It is also possible to use negative values in ranges, as explained next.

Range Values

Crystal allows one to define Ranges that have end value smaller than begin. Such objects will simply not contain any elements.

Because creating such ranges is allowed, VirtualTime detects such cases and creates copies of objects with values converted to positive and in the correct order.

In other words, if you specify a range of say, day: (10..-7).step(2), this will properly match every other day from 10th to a day 7 days before the end of a particular month. Very useful!

Week Numbers

Another interesting case are week numbers, which are 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 a new year can still technically belong to the last week of the previous year.

Therefore, this field can have values between 0 and 53 inclusively. Value 53 indicates a week that has started in one year (53rd Monday seen in a year), but at least one (and up to 3) of its days will surely overflow into the new year.

Similarly, a week number 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.

Note 1: if you want to match the first or last 7 days of a year irrespective of weeks, you should use day: 1..7 or day: -7..-1 instead of week: 1 or week: -1.

Note 2: it is allowed to specify week: 53, day_of_week: 7. That is sure to represent a day whose week number and actual date are in different years. For example, 2027-01-03 would match this rule and represent the last day of 2026's 53th calendar week.

Days in Month and Year

As mentioned above, VirtualTime is aware of the number of days in months and years when Time objects are involved.

But for VirtualTime objects which do not necessarily have the month or year value specified, helper functions days_in_month and days_in_year return 0.

As a consequence, when matching VirtualTimes to other VirtualTimes, any negative values (such as day: -1) remain negative (i.e. not converted to actual values) and are matched directly.

This choice was made because it is only possible to know the number of days in a month if both year and month are defined and contain integers. If they are not both defined, or they contain a value of any other type (e.g. a range 2023..2030), it is ambiguous or indeterminable what the exact value should be.

So comparing VTs to VTs is always done without the conversion of negative values to actual values.

Unsupported Comparisons

Comparisons between VirtualTime property values which are both a Proc are not supported and will throw ArgumentError in runtime.

Comparisons between VirtualTime objects with different location values are not supported and will throw ArgumentError in runtime.

Level of Granularity

VirtualTime performs all internal calculations using maximum precision available from the Time struct (which is nanoseconds), but since the primary intended usage is for human scheduling, the default granularity is 1 minute, with seconds and nanoseconds defaulting to 0.

To increase granularity, simply specify interval and/or step arguments manually (e.g. interval = 1.second) instead of defaulting to 1.minute.

In some other cases, the default interval of 1 minute could be too small. For example, if VirtualTime was created with only the hour value specified, it would match (and also generate) an event on every minute of that hour.

In that case, you could easily request the step to be e.g. 1 hour or 1 day, so that there would be reasonable space between the generated Times.

Here are examples for both cases:

vt = VirtualTime.new
vt.year = 2020..2030
vt.day = -8..-1
vt.day_of_week = [6,7]

vti = vt.step(1.minute)
2.times do p vti.next end
# 2024-01-27 11:16:00.0 +01:00 Local
# 2024-01-27 11:17:00.0 +01:00 Local

vti = vt.step(1.day)
2.times do p vti.next end
# 2024-01-27 11:16:00.0 +01:00 Local
# 2024-01-28 11:16:00.0 +01:00 Local

Materialization

"Materialization" is a process of converting all VirtualTime's field values to specific integers.

VirtualTimes often need to be materialized for display, calculation, comparison, or further conversion.

An obvious such case is when to_time() is invoked on a VT, because a Time object must have all of its fields set to some integer value.

(The difference between #materialize and #to_time is that materialize produces another VT with its fields materialized, while to_time further converts it to a Time instance.)

Because VirtualTimes can be very broadly defined, often times there are many equal choices to which they can be materialized. For example, if a VT matches everything in the month of March, which specific value should it be materialized to?

To avoid the problem of too many choices, materialization takes as an argument a Time hint. The materialized time will be equal to that time or moved to the future as little as necessary to satisfy all VT's constraints.

For example:

vt = VirtualTime.new

# These fields will be used as-is since they have a value:
vt.year = 2018
vt.day = 15
vt.hour = 0

# While others (which are nil) will have their value inserted from the "hint" object:
hint = Time.local # 2023-12-09 12:56:26.837441132 +01:00 Local

vt.materialize(hint).to_tuple # => {2018, 12, 15, nil, nil, nil, 0, 56, 26, nil, 837441132, nil}
p vt.to_time(hint) # => 2018-02-15 00:56:26.837441132 +01:00 Local

If not specified, the time hint defaults to Time.local.at_beginning_of_minute.

Time Zones

VirtualTime is timezone-agnostic. Values are compared against VirtualTime values as-is.

However, VirtualTime has property #location which, if set and different than Time's #location, will cause the time to be duplicated and have its timezone converted to VirtualTime's location before matching.

For example:

vt = VirtualTime.new
vt.hour = 16..20

t = Time.local 2023, 10, 10, hour: 18, location: Time::Location.load("America/New_York")
vt.matches?(t) # => true, because hours `16..20` include hour `18`

t = Time.local 2023, 10, 10, hour: 0, location: Time::Location.load("Europe/Berlin")
vt.matches?(t) # => nil, because 00 hours is not between 16 and 20

vt.location = Time::Location.load("America/New_York")
vt.matches?(t) # => true, because time instant 0 hours converted to NY time (-6) is 18 hours

Since it is timezone-agnostic, any timezones present in hints propagate to final Time objects:

vt = VirtualTime.new
vt.to_time(Time.local(1970,1,1)) # => 1970-01-01 00:00:00+01:00[Europe/Berlin]
vt.to_time(Time.unix(0))         # => 1970-01-01 00:00:00Z

Matching VTs to VTs with timezones is also possible as long as the timezone is equal; otherwise a runtime error is thrown as already mentioned above under "Unsupported Comparisons".

Durations

VirtualTime objects are not designed or intended to represent durations.

While this may seem possible at first, for example by specifying #hour = 11..13, it is not generally viable because there is no way to define a duration of e.g. 2.5 hours from 11:00 to 13:30.

For such higher level constructs, see https://github.com/crystallabs/virtualdate.

Tests

Run crystal spec or just crystal s.

API Documentation

Run crystal docs or crystal do and firefox ./docs/index.html.

Other Projects

List of interesting or similar projects in no particular order: