virtualtime
Version, currently 1.6.028 versions
- 1.8.0latestAug 1, 2026
- 1.7.0not indexedAug 1, 2026
- 1.6.1not indexedAug 1, 2026
- 1.6.0not indexedAug 1, 2026
- 1.5.0not indexedAug 1, 2026
- 1.4.1not indexedAug 1, 2026
- 1.4.0not indexedAug 1, 2026
- 1.3.3not indexedAug 1, 2026
- 1.2.3not indexedAug 1, 2026
- 1.2.2not indexedAug 1, 2026
- 1.2.1not indexedAug 1, 2026
- 1.2.0not indexedAug 1, 2026
- 1.1.6not indexedAug 1, 2026
- 1.1.5not indexedAug 1, 2026
- 1.1.4not indexedAug 1, 2026
- 1.1.3not indexedAug 1, 2026
- 1.1.2not indexedAug 1, 2026
- 1.1.1not indexedAug 1, 2026
- 1.1.0not indexedAug 1, 2026
- 1.0.1not indexedAug 1, 2026
- 1.0.0not indexedAug 1, 2026
- 0.5.2not indexedAug 1, 2026
- 0.5.1not indexedAug 1, 2026
- 0.5.0not indexedAug 1, 2026
- 0.4.0not indexedAug 1, 2026
- 0.3.0not indexedAug 1, 2026
- 0.2.0not indexedAug 1, 2026
- 0.1.0not indexedAug 1, 2026
github.com/crystallabs/virtualtime
Advanced time, calendar, schedule, and remind library for Crystal
23 stars
0 dependents
License: AGPL-3.0
Nothing has been indexed for 1.6.0 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.
Installation
# Add this to your shard.yml
dependencies:
virtualtime:
github: crystallabs/virtualtime
version: ~> 1.6.0Then run:
shards installshard.yml
No shard.yml has been indexed for 1.6.0. You can read it on the repository.
Dependencies
Unknown: the shard.yml for this version has not been read yet.
README
This README is the one indexed from the repository at its latest ref, not from the tag for this version.
[](https://github.com/crystallabs/virtualtime/actions?query=workflow%3A%22Linux+CI%22+event%3Apush+branch%3Amaster)
[](https://github.com/crystallabs/virtualtime/releases/latest)
[](https://github.com/crystallabs/virtualtime/blob/master/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, `VirtualTime`s 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 `Time`s
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:
- Day of month should be between -7 and -1 (the last 7 days of any month)
- Day of week should be 6 or 7 (Saturday or Sunday)
Then we can check if the current Time matches it:
```cr
vt = VirtualTime.new
vt.day = -7..-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 `Time`s, it is also possible to match `VirtualTime`s 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:
```cr
# Same VT as before:
vt = VirtualTime.new
vt.day = -7..-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 `Time`s 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:
```cr
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:
```cr
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:
```cr
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:00Z
2020-02-22 00:00:00Z
2020-03-28 00:00:00Z
2020-04-25 00:00:00Z
2020-05-30 00:00:00Z
2020-06-27 00:00:00Z
2020-07-25 00:00:00Z
2020-08-29 00:00:00Z
2020-09-26 00:00:00Z
2020-10-24 00:00:00Z
```
Now let's take a look at implementation details.
## Supported Property Values
All VirtualTime instances contain the following properties:
1. **Year** (0..9999)
1. **Month** (1..12)
1. **Day** (1..31)
1. **Week number of year** (1..53, ISO 8601)
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**, to default matches to `VirtualTime.default_match? : Bool = true`
1. **Boolean**, to always match (`true`) or fail (`false`)
1. **Int32**, to match a specific value such as 5, 12, 2023, -1, or -5
1. **Array or Set of Int32s**, such as [1,2,10,-1] to match any value in list
1. **Range of Int32..Int32**, such as `10..-1` to match any value in range
1. **Range with step**, e.g. `day: (10..20).step(2)`, to match any value in range with step
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.
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:
```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) # 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 `Time`s,
but may match similar `VirtualTime`s.
It is also possible to use negative values in ranges, as explained next.
### Range Values
Crystal allows one to define `Range`s that have `end` value smaller than `begin`.
Such objects will simply not contain any elements.
Because negative values *are* allowed, VirtualTime creates copies of such objects with
the values converted to their positive equivalents before comparing.
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!
Note that the endpoints are converted but never swapped: a range that still ends before it
begins after conversion contains no elements and so matches nothing. Wrapping ranges are
not supported, so `hour: -1..1` is an empty rule rather than "23:00 through 01:00" — write
that as `hour: [23, 0, 1]`, or as two separate rules.
### Week Numbers
Another interesting case are week numbers, which follow ISO 8601 (the same numbering
Crystal's `Time#calendar_week` produces). Weeks start on Monday, and week 1 is the week
containing January 4. But not every year starts on Monday, so up to the first 3 days of a
new year can still belong to the last week of the previous year — and, likewise, up to the
last 3 days of a year can already belong to week 1 of the next one.
Therefore, this field can have values between 1 and 53 inclusively. Value 53 indicates the
last week of a year that has 53 of them (one that begins on a Thursday, or a leap year
beginning on a Wednesday), and at least one (and up to 3) of its days will surely overflow
into the new year.
Since weeks are counted within the ISO year and not the calendar year, the first days of
January belonging to the previous year's last week match `week: 52` or `week: 53` — never
`week: 0`, which matches nothing.
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 `VirtualTime`s to other `VirtualTime`s, 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.
One consequence: a range written across the two sign domains, such as `day: 10..-7`,
cannot be resolved without knowing the month's length. Against a `Time` it works as
described above, but in VT-to-VT comparisons its raw form contains no values, so it
behaves like `false` there — it matches nothing, not even an identical rule. Scalars
and lists (e.g. `day: [10, -7]`), and ranges written within one sign domain
(`-7..-1`), compare fine.
### 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 `Time`s.
Here are examples for both cases:
```cr
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:
```crystal
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-12-15 00:56:26.837441132+01:00[Europe/Berlin]
```
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:
```cr
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) # => false, 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 `hint`s propagate to final
`Time` objects:
```cr
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.
## Serialization
`VirtualTime` includes `YAML::Serializable`, so `#to_yaml` and `.from_yaml` are
available. Each field is written as a scalar, in one of these forms:
| Value | YAML |
|-----------------|---------------|
| `nil` | (field omitted) |
| `true`/`false` | `true`/`false` |
| `5`, `-1` | `5`, `-1` |
| `[1, 2, -1]` | `1,2,-1` |
| `10..20` | `10..20` |
| `10...20` | `10...20` |
| `(10..20).step(2)` | `10..20/2` |
| `Proc` | not supported |
All forms round-trip, so `VirtualTime.from_yaml(vt.to_yaml).to_yaml == vt.to_yaml`.
Note that a `Set(Int32)` is written in list form and reads back as an `Array(Int32)`.
Procs cannot be serialized: `#to_yaml` raises rather than write a value that
would silently read back as something else. Set Proc-valued fields in code after
loading.
```cr
vt = VirtualTime.new
vt.month = 3
vt.day = [1, -1]
vt.hour = (10..20).step 2
vt.to_yaml # => "---\nmonth: 3\nday: 1,-1\nhour: 10..20/2\ndefault_match: true\n"
```
## Tests
Run `crystal spec` or just `crystal s`.
Longer-running randomized and brute-force checks live in `fuzz/`; run them all
with `fuzz/run.sh` (see `fuzz/README.md`). They are suited for release checks
or a nightly CI job rather than every test run.
## 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:
- https://dianne.skoll.ca/projects/remind/ - a traditional calendar and alarm program
Documentation
Built from the current release. The first visit to a release nobody has asked for starts its build.
Links
This release
- Version
1.6.0- Tagged
- Aug 1, 2026
- Commit
9fee00b9ef55- Indexed
- not yet
Dependents
No indexed shard depends on this one yet.
Repository
github.com/crystallabs/virtualtime
Metadata
- Created
- Aug 12, 2026
- Updated
- Aug 12, 2026
- Synced
- Aug 12, 2026
- Versions
- 28