Periods: A library for manipulating time

Table of Contents

Next: , Previous: , Up: (dir)   [Contents]

Overview

Welcome to the PERIODS library. The intention of this code is to provide a convenient set of utilities for manipulating times, distances between times, and both contiguous and discontiguous ranges of time. By combining these facilities in various ways, almost any type of time expression is possible.


Next: , Previous: , Up: Top   [Contents]

1 Introduction

Consider you are writing a calendaring application which must support the idea of recurring tasks. Often, people using a calendar have very certain ideas of what type of recurrence they want—but it is not always easy to calculate. Say they are entering their pay days; this could either fall bi-weekly, the second Friday of every month, or every 15 days, but the following Monday if that day falls on a weekend. How is your code to calculate these moments in time, without resorting to contortionist uses of encode-universal-time and decode-universal-time?

By way of a brief introduction to the following sections, here is how each of the above time expressions would be calculated. The first three occurrences of each are shown, starting from ‘Sun, 18 Nov 2007’. This code generates a list of dates occurring bi-weekly, or every 14 days, until next year:

;; The date is written with (local-time:enable-read-macros)
(list-times @2007-11-18 (duration :days 14) (next-year))
;; ⇒
@2007-12-02T01:00:00.000000+01:00
@2007-12-16T01:00:00.000000+01:00
@2007-12-30T01:00:00.000000+01:00
@2008-01-13T01:00:00.000000+01:00
…

And this code, using the system :period-series, generates an unbounded series occurring bi-weekly, or every 14 days:

(scan-times @2007-11-18 (duration :days 14))
   ⇒ #Z(@2007-12-02 @2007-12-16 @2007-12-30 …)

This next example is a bit more involved, as it unifies the idea of duration-based time stepping (monthly) with relative-time. The result is an unbounded sequence representing the second Friday of every month.

(mapping ((time (scan-times (previous-time @2007-11-18
                                           (relative-time :day 1))
                            (duration :months 1))))
  (next-time (next-time time (relative-time :day-of-week 5)
                        :accept-anchor t)
             (relative-time :day-of-week 5)))
  ⇒ #Z(@2007-12-14 @2008-01-11 @2008-02-08 …)

To read this example briefly: Beginning with the first day of the current month, advance forward one month ad infinitum. For each new month, scan forward to the first Friday (accepting the first day if it is Friday, with :accept-anchor). Then scan to the Friday after that.

In the next example, we want to step through time every 15 days, but select the following Monday if the 15th day falls on a weekend.

(mapping ((time (scan-times @2007-11-03 (duration :days 15))))
  (if (falls-on-weekend-p time)
      (next-time time (relative-time :day-of-week 1))
      time))
  ⇒ #Z(@2007-11-19 @2007-12-03 @2007-12-18 …)

The starting time for this example is set to ‘3 Nov 2007’, so that Monday-shifting could be demonstrated. Note how the shift does not disrupt the regular 15-day cycle, it merely causes the paycheck to be delivered one day late in that instance.

Hopefully this gives an idea of the power and flexibility of the periods library, especially if used in combination with the SERIES library1, since times are naturally expressable as an unbounded series supporting lazy calculation.


Next: , Previous: , Up: Top   [Contents]

2 Installation

The PERIODS library depends on two required dependencies:

If you also wish to use the SERIES library with PERIODS, it must be installed as well, version 2.2.9 or later.

The recommended way to install the library is to use Quicklisp, which installs the dependencies automatically.

Here is how to load the basic PERIODS library:

* (ql:quickload :periods)

Loading the series-enabled PERIODS functions:

* (ql:quickload :period-series)

Next: , Previous: , Up: Top   [Contents]

3 Fixed time

The most basic element of time used by the PERIODS library is the fixed-time structure. At present, this is just a type alias for local-time structures2, so all of the operations possible on a local-time are applicable to a fixed-time. In addition, fixed-time’s may be constructed by a convenience function of the same name, which allows for quickly creating time structures anchored in the current year.

Function: fixed-time &rest args

Return a fixed point in time relative to the time of the call. args is a property list giving a specific precision for the return value.

If the keyword argument :now is given, all else is ignored; this is equivalent to calling local-time:now.

Otherwise, any keyword arguments given override their corresponding elements in the current time. Further, any elements smaller in resolution than the finest specified element are reduced to 0 or 1, according to their position.

For example, assuming the current time is "@2007-11-17T23:02:00.000", compare these outputs:

(fixed-time :month 4) ;; ⇒ @2007-04-01T00:00:00.000
(fixed-time :day 10)  ;; ⇒ @2007-11-10T00:00:00.000
(fixed-time :hour 15) ;; ⇒ @2007-11-17T15:00:00.000

This behavior makes it very easy to return a fixed time for "april of this year", etc. If you wish to determine the date of the previous April, while preserving the current day of the month, hour of the day, etc., then see the function previous-time.


Previous: , Up: Fixed time   [Contents]

3.1 Helper functions

There are a few helper functions for performing common operations on fixed-time’s:

Function: year-of fixed-time
Function: month-of fixed-time
Function: day-of fixed-time
Function: hour-of fixed-time
Function: minute-of fixed-time
Function: second-of fixed-time
Function: millisecond-of fixed-time

Return the corresponding detail associated with a given fixed-time. All results are of type fixnum.

Function: day-of-week fixed-time

Return the day of the week associated with a given fixed-time. The result is a fixnum with 0 representing Sunday, through 6 on Saturday.

Function: falls-on-weekend-p fixed-time

Return t if the given fixed-time occurs on a Saturday or Sunday.

Function: current-year

Return the current year as a fixnum.

Function: leapp year

Return t if year falls on a leap year.

Function: days-in-month month &optional year

Returns the number of days in the given month of the specified year.

Function: floor-time fixed-time &optional resolution

Reduce a fixed time to be no finer than resolution.

For example, if the date is 2007-04-20, and the resolution is :month, the date is floored to 2007-04-01. Anything smaller than the resolution is reduced to zero (or 1, if it is a day or month being reduced).

Function: find-smallest-resolution step-by

Examine the property list step-by and return the smallest unit of time specified.

For example, given the following property list:

(:DAY 10 :HOUR 5 :MINUTE 2)

The result is :minute.

Function: add-time fixed-time duration &key reverse

Given a fixed-time, add the supplied duration.

Example (reader notation requires calling LOCAL-TIME:ENABLE-READ-MACROS):

(add-time @2007-05-20T12:10:10.000 (duration :hours 50))
  ;; ⇒ @2007-05-22T14:10:10.000

note: This function always adds the largest increments first, so:

(add-time @2003-01-09 (duration :years 1 :days 20)) ;; ⇒ @2004-02-29

If days has been added before years, the result would have been "@2004-03-01".

Function: time-difference left right

Compute the duration existing between fixed-times left and right.

The order of left or right is ignored; the returned duration, if added to the earlier value, will result in the later.

A complexity of this process which might surprise some is that larger quantities are added by add-time before smaller quantities. For example, what is the difference between 2003-02-10 and 2004-03-01? If you add years before days, the difference is 1 year and 20 days. If you were to add days before years, however, the difference would be 1 year and 21 days. The question, do you advance to 2004 and then calculate between 2-10 and 3-01, or do you move from 2-10 to 3-01, and then increment the year? This library chooses to add years before days, since this follows human reckoning a bit closer (i.e., a person would likely flip to the 2004 calendar and then start counting off days, rather than the other way around). This difference in reckoning can be tricky, however, so bear this in mind.


Next: , Previous: , Up: Top   [Contents]

4 Time durations

The basic element is the duration structure. It has the slots years, months, days, hours, minutes, seconds down to nanoseconds.

Create a duration with the duration function. It accepts a key argument for each slot:

(duration :days 3)
;; =>
#S(DURATION
   :YEARS 0
   :MONTHS 0
   :DAYS 3
   :HOURS 0
   :MINUTES 0
   :SECONDS 0
   :MILLISECONDS 0
   :MICROSECONDS 0
   :NANOSECONDS 0)
Function: duration &rest args

Create a duration object.

One thing to note about duration: there is no way to determine the total length of a duration in terms of any specific time quantity, without first binding that duration to a fixed point in time (after all, how many days are in a month if you don’t know which month it is?) Therefore, those looking for a function like "duration-seconds" are really wanting to work with ranges, not just durations.

Access each duration slot with its accessor: duration-days and so on.

4.1 Looping over durations

Macro: do-times (var start duration end &optional result) &body body

Evaluate body where var is bound to a time starting at start + duration, separated by duration, until and excluding end.

A ’do’ style version of the functional map-times macro.

The disadvantage to do-times is that there is no way to ask for a reversed time sequence, or specify an inclusive endpoint.

Return nil.

Example:

;; when now is @2023-11-14T09:05:00
(do-times (time (now)
              (duration :hours 1)
              (next-day))
  (print time))
;; =>
@2023-11-14T10:04:31.475324+01:00
@2023-11-14T11:04:31.475324+01:00
@2023-11-14T12:04:31.475324+01:00
[…]
@2023-11-14T23:04:31.475324+01:00
NIL
Macro: map-times callable start duration end &key reverse inclusive-p

Map over a set of times separated by duration, calling callable with the start of each.

Example:

  (map-times #'print (now) (duration :hours 1) (next-day))

4.2 Duration helper functions

Function: add-duration left right

Add one duration to another.

Function: subtract-duration left right

Subtract one duration from another.

Function: multiply-duration left multiplier

Multiply one duration to another.


Next: , Previous: , Up: Top   [Contents]

5 Relative time

A relative time allows to work with ranges.

A relative-time structure has the slots year, month, week, day-of-week, day, hour, minute, second down to nanosecond.

Function: next-time anchor relative-time &key reverse accept-anchor recursive-call

Compute the first time after fixed-time which matches relative-time.

This function finds the first moment after fixed-time which honors every element in relative-time:

(next-time @2007-05-20 (relative-time :month 3)) ;; ⇒ @2008-03-20

The relative time constructor arguments may also be symbolic:

(relative-time :month :this)
(relative-time :month :next)
(relative-time :month :prev)

To find the date two weeks after next February, a combination of next-time and add-time must be used, since "next February" is a relative time concept, while "two weeks" is a duration concept:

(add-time (next-time @2007-05-20 (relative-time :month 2))
  (duration :days 14))

note: The keyword arguments to relative-time are always singular; those to duration are always plural.

The following form resolves to the first sunday of the given year:

(next-time (previous-time @2007-05-20
                  (relative-time :month 1 :day 1))
   (relative-time :week-day 0))

This form finds the first Friday the 13th after today:

(next-time @2007-05-20 (relative-time :day 13 :day-of-week 5))

note: When adding times, next-time always seeks the next time that fully honors your request. If asked for Feb 29, the year of the resulting time will fall in a leap year. If asked for Thu, Apr 29, it returns the next occurrence of Apr 29 which falls on a Friday. Example:

(next-time @2007-11-01
   (relative-time :month 4 :day 29 :day-of-week 4))
;; ⇒ @2010-04-29T00:00:00.000
Function: previous-time anchor relative-time &key accept-anchor

This function is the reverse of ‘NEXT-TIME’. Please look there for more.

Macro: map-relative-times callable anchor relative-time end &key reverse inclusive-p
Macro: list-relative-times anchor relative-time end &key reverse inclusive-p
Macro: do-relative-times var anchor relative-time end &key reverse inclusive-p

5.1 Helper functions for the current date

There are helper functions to get dates relative to the current date:

Function: this-year
Function: this-month
Function: today
Function: this-day
Function: this-hour
Function: this-minute
Function: this-second
Function: next-year
Function: next-month
Function: next-day
Function: previous-year

and also:

Function: next-monday

up to next-sunday, previous- versions,

Function: year-begin

and also sunday-week-begin, monday-week-begin, day-begin, hour-begin down to microsecond, and end- versions,

and more.


Next: , Previous: , Up: Top   [Contents]

6 Time ranges

Function: time-range &rest args

Create a time-range.

Params: the time-range struct slots (:begin, :end, :duration…).

Example:

  (time-range :begin (next-day) :end (next-sunday-week))
  ;; =>
  #S(PERIODS:TIME-RANGE
   :FIXED-BEGIN NIL
   :BEGIN @2023-11-14T00:00:00.000000+01:00
   :BEGIN-INCLUSIVE-P T
   :FIXED-END NIL
   :END @2023-11-19T17:51:58.625785+01:00
   :END-INCLUSIVE-P NIL
   :DURATION NIL
   :ANCHOR NIL)

Use its accessor functions: time-range-begin etc.

Function: time-range-next range
Function: time-range-previous range
Function: periods:time-within-range-p fixed-time range

Return t if fixed-time is with this time-range.

Example:

(time-within-range-p (local-time:now) (time-range :begin (periods:previous-day) :end (periods:next-day)))

Function: time-within-range-p fixed-time range

Return t if fixed-time is with this time-range.

Example:

(time-within-range-p (local-time:now) (time-range :begin (previous-day) :end (next-day)))

Function: year-range fixed-time
Function: month-range fixed-time

down to second-range.


Previous: , Up: Top   [Contents]

7 Time periods

Macro: with-timestamp-range (min-symbol max-symbol &optional update) &body body

Define a context where (1) min-symbol and max-symbol are locally bound variables with nil default values and (2) update names a lexically bound function which takes a timestamp and updates the variables min-symbol and max-symbol so that they respectively hold the earliest and latest timestamp after successive invocations. That function finally returns its input value. For example, the following code builds a time-range instance from a list of dated transactions.

    (with-timestamp-range (earliest latest)
      (dolist (tt transaction)
        (update-range (transaction-date tt)))
      (time-range :begin earliest :end latest :end-inclusive-p t))

A custom name can be used to nest invocations:

    (with-timestamp-range (earliest latest global-update-range)
      (dolist (jj journals)
        (with-timestamp-range (<< >>)
          (dolist (tt (journal-xact jj))
            (gloal-update-range
              (update-range (transaction-date tt))))
          (format t "Journal earliest / latest: ~A / ~A~%" << >>)))
      (format t "Global earliest / latest: ~A / ~A~%" earliest latest))

8 General purpose

Function: sleep-until fixed-time

Footnotes

(1)

See http://series.sourceforge.net/. To use the series-enabled functions, load the PERIOD-SERIES ASDF system.

(2)

See http://common-lisp.net/project/local-time/.