gren-civil-time

Calendar dates and clock times, with or without an offset from UTC.

Gren's core Time module deals in moments. A Posix is one exact point in time, the same everywhere in the world, and that is the right type for recording when something happened. It is the wrong type for a birthday, a shop's opening hour, the date on an invoice, or the 1979-05-27T07:32:00 in a config file. Those are readings from a calendar and a clock. They only become moments once you know where they were read.

This package provides the types for those readings. This package was originally written to support reading TOML files, so that's why there are many references to TOML in the docs and in the tests.

import Civil.DateTime as DateTime

DateTime.fromString "1979-05-27 07:32:00-07:00"
    |> Maybe.map DateTime.toString
--> Just "1979-05-27T07:32:00-07:00"

The four shapes

RFC 3339 is the standard for writing dates and times as text, and TOML uses it for its date and time values. Both distinguish four shapes. So does this package:

example what it is module
1979-05-27 a date Civil.Date
07:32:00 a time of day Civil.Time
1979-05-27T07:32:00 a date and a time, but still not a moment Civil.DateTime
1979-05-27T07:32:00-07:00 a moment Civil.DateTime

The last shape carries an offset: how far the clock was ahead of or behind UTC when the reading was taken. That is enough to pin down a moment, so DateTime.toPosix works on it. The third shape has no offset, so toPosix returns Nothing for it.

Turning the third shape into a moment needs a time zone, such as America/Denver. A time zone is a set of rules for which offset applies when, and those rules change over the years. They live in the IANA time zone database, which is far larger than this package and is deliberately left out of it.

Every value is valid

The only way to build a value is through a constructor, and every constructor checks its input. 2023-02-29, 24:00:00, +25:00 and 1979-5-27 all give Nothing, so code that receives a value never has to check it again.

Civil.Date.fromString "2100-02-29"   --> Nothing   -- 2100 is not a leap year
Civil.Date.fromString "2024-02-29"   --> a date

Years run from 1 to 9999, the range RFC 3339 allows.

Three ways to write a zero offset

Z, +00:00 and -00:00 all mean zero minutes from UTC, but RFC 3339 gives them three different meanings. Z says the time is in UTC. +00:00 is an ordinary offset that happens to be zero. -00:00 means the offset is unknown. Civil.Offset keeps the three apart: they are different values under ==, but toMinutes returns 0 for all of them. Use whichever comparison you need.

Fractions of a second are kept in full

There is no millisecond or nanosecond limit. The digits after the decimal point are stored as text, however many there are, so nothing is rounded away. Trailing zeroes are dropped, so 10:32:00.5 and 10:32:00.50 are the same value.

Leap seconds

23:59:60 parses, because RFC 3339 allows a leap second and a timestamp that records one is valid. The package does no arithmetic that would need to decide what a leap second means. secondOfDay simply returns 86400 for it, one past the last ordinary second of the day.

The 60 is checked as a seconds field on its own, not as part of an HH:MM:SS that has to read 23:59:60. 12:30:60 parses too, and secondOfDay gives it 45060. That is RFC 3339's grammar, which allows 60 in the seconds field and leaves the question of whether a leap second was really inserted at that instant to whoever knows. It has to: a leap second falls at 23:59:60 UTC, which is some other wall clock at every other offset, so the same second is 1990-12-31T15:59:60-08:00 in California.

Tests

git clone <this repo>     # --recurse-submodules is optional; see below
devbox run test

The suite is 101 checks and runs in a few milliseconds.

Dates              ok    17/17  (3 ms)
Clocks             ok    33/33  (3 ms)
Conformance        ok     3/3   (1 ms)
Examples.Date      ok    18/18  (0 ms)
Examples.Time      ok    14/14  (0 ms)
Examples.Offset    ok     9/9   (0 ms)
Examples.DateTime  ok     7/7   (1 ms)

Ran 101 tests in 8 ms

OK — 101 passed

What each suite checks:

suite what it checks
Dates Forty dates against Python's datetime.date, whose toordinal uses the same day numbering as toRataDie. Ten are dates where calendar code tends to break and thirty are random across the whole range. Also the constructor and the accessors called directly.
Clocks What the TOML suite does not reach: the three zero offsets, the fraction, leap seconds, the Posix round trip on both sides of 1970, toPosix against Python-computed values for real offsets, and which characters count as digits.
Conformance Every date and time in the official toml-test suite: 32 that must parse and 70 that must not.
Examples.* Every --> example in the doc comments, 48 of them, checked against the value it claims.

The slow checks

devbox run probe

tests/src/Probe.gren walks every one of the 3,652,059 days from year 1 to year 9999, using only the length of each month, and checks at each day that the day number, the day of the year, the weekday, the text form and the Posix all agree with the walk. It then sends every second of a day through Posix and back, and every offset through text and back. It takes about ten seconds, so it has its own runner and is not part of devbox run test.

The submodule is only for regenerating tests

tests/src/Dates.gren, tests/src/Conformance.gren and the four modules under tests/src/Examples/ are generated, and the generated files are committed.

The conformance generator reads the TOML test corpus from vendor/toml-test, a git submodule pinned to one commit. It is pinned because the generator writes the number of cases it found into a guard test. Regenerating against a newer corpus would change that number and fail the guard. The examples generator reads the doc comments in src/.

The generators do not contain any Gren. The Gren lives in tools/templates/, one Jinja2 template per generated module. Each script gathers its data and renders its template.

devbox run gen                       # all three, then gren-format on the examples

python3 tools/gen-dates.py           # needs Python and Jinja2
python3 tools/gen-conformance.py     # also needs the submodule
python3 tools/gen-examples.py        # then gren-format tests/src/Examples/

If you cloned without --recurse-submodules, the tests still pass. Only the conformance generator fails:

FileNotFoundError: [Errno 2] No such file or directory:
    'vendor/toml-test/tests/files-toml-1.1.0'

Fetch the submodule in place. No re-clone is needed:

git submodule update --init

devbox run gen reproduces every generated file byte for byte, so a diff after regenerating means a script and its output have drifted apart. Two safeguards keep a broken generator from producing an empty suite that passes: gen-conformance.py writes the number of cases it found into a guard test, and gen-examples.py refuses to write a module in which it found no examples.