Module
sysl.time
The calendar: a date and a time of day with no zone attached, and the arithmetic that turns one into the other.
import sysl.time
The calendar: a date and a time of day with no zone attached, and the arithmetic that turns one into the other.
A local date-time is a reading on a wall clock. It is not a point on the timeline and it does
not become one until somebody says where the wall is. Kept apart from Instant it is an ordinary,
boring value: a day number and a count of microseconds since midnight, both plain integers with no
calendar hidden inside.
A day number, not packed civil fields. The alternative — year, month, day, hour and minute as bit fields inside one word — is the design most people reach for, and the arithmetic says no. Packing to the nanosecond needs 30 bits for the nanosecond, 17 for the time of day and 9 for the day and month, which is 56, leaving 8 bits of year: a range of ±128 years, which is not a calendar. Dropping to microseconds leaves 18 bits of year and does fit. But a count fits more comfortably at the same width — microseconds within a day need 37 bits, leaving 27 for the day number and a range of ±183,000 years — and it costs nothing to compute with, because comparing, subtracting and adding a length of time are one instruction each on a count and an unpack-recompute-repack on packed fields. Only “what month is it” wants the fields, and that is the rare question, paid for below where it is asked.
Index
us_per_day us_per_hour us_per_milli us_per_minute us_per_second at_offset civil_from_days date_at date_text datetime_at datetime_text day_of day_of_year days days_from_civil days_in_month floor_div floor_mod from_offset hour_of hours instant_text leap_year micros millis minute_of minutes month_name month_of odd_hours odd_minutes odd_seconds odd_us offset_text on_or_after parse_date parse_datetime parse_offset parse_time parse_timestamp plus_days plus_months plus_years resolve second_of seconds since time_at time_text timestamp_text us_of wall_of wall_us weekday weekday_name whole_days whole_hours whole_micros whole_millis whole_minutes whole_seconds year_of Civil Duration Instant LocalDate LocalDateTime LocalTime Offset Resolution TimeParseError Weekday DurationUnits Add for Duration Add for Instant Display for Instant Display for LocalDate Display for LocalDateTime Display for LocalTime Display for Offset Display for Resolution Display for TimeParseError DurationUnits for T Eq for Duration Eq for Instant Eq for LocalDate Eq for LocalDateTime Eq for LocalTime Eq for Offset Mul for Duration Neg for Duration Ord for Duration Ord for Instant Ord for LocalDate Ord for LocalDateTime Ord for LocalTime Ord for Offset Sub for Duration Sub for Instant Sub for Instant
Constants
us_per_day
const us_per_day: long = 86400000000
us_per_hour
const us_per_hour: long = 3600000000
us_per_milli
const us_per_milli: long = 1000
us_per_minute
const us_per_minute: long = 60000000
us_per_second
const us_per_second: long = 1000000
Functions
at_offset
at_offset(t: Instant, o: Offset) -> LocalDateTime
An instant read against a wall set a fixed distance from UTC, and back. Both directions are total, which is what separates a fixed offset from a zone: a zone whose clocks move answers the second question with none or two, and this pair never does, because an offset is a number rather than a rule with a history.
That is also why they are here and a zone is not. A timestamp on a wire, in a log or in a database column carries the offset that was in force when it was written — the sender resolved the zone already, and what arrived is the record of that decision. Reading one back needs no table, no update cadence and no filesystem, which is the whole of what makes it the library’s business.
civil_from_days
civil_from_days(d: LocalDate) -> Civil
date_at
date_at(y: int, m: int, d: int) -> LocalDate
date_text
date_text(d: LocalDate) -> string
datetime_at
datetime_at(y: int, mo: int, d: int, h: int, mi: int, s: int) -> LocalDateTime
datetime_text
datetime_text(ldt: LocalDateTime) -> string
day_of
day_of(d: LocalDate) -> int
day_of_year
day_of_year(d: LocalDate) -> int
The day of the year, from 1 on the first of January. It is a subtraction rather than a sum over the months, because the day numbers are already what the subtraction needs.
days
days(n: long) -> Duration
days_from_civil
days_from_civil(c: Civil) -> LocalDate
Howard Hinnant’s civil-from-days pair, which is the standard way to do this and is worth using rather than rediscovering. It works by shifting the epoch to the start of a 400-year era, after which every quantity in it is non-negative and the leap rules become one exact division apiece.
days_from_civil is its own inverse over the whole int range, which is the property to assert
rather than any particular date.
days_in_month
days_in_month(y: int, m: int) -> int
How many days a month has, which is the one calendar question that cannot be answered by counting.
floor_div
floor_div(a: long, b: long) -> long
Division that rounds toward minus infinity, which is the only kind a calendar can use: the day
containing an instant is floor(us / us_per_day), and a / that truncates toward zero puts every
moment of the day before the epoch into the day after it.
floor_mod
floor_mod(a: long, b: long) -> long
from_offset
from_offset(ldt: LocalDateTime, o: Offset) -> Instant
hour_of
hour_of(t: LocalTime) -> int
hours
hours(n: long) -> Duration
instant_text
instant_text(t: Instant) -> string
An instant written down, which is the one rendering here that needs no zone: an offset of zero is
what the count is measured from rather than a conversion applied to it, so this reports the value
rather than interpreting it. Z says which, in the spelling ISO 8601 gives that offset.
It is the module’s because coherence makes it the module’s alone. Display belongs to the library
and so does Instant, so no program naming neither of them may write an impl for the pair — and
a type nothing may render is a type that renders nowhere at all.
leap_year
leap_year(y: int) -> bool
Whether a year has a 29th of February, by the rule the proleptic Gregorian calendar states: every fourth year, except every hundredth, except every four-hundredth.
micros
micros(n: long) -> Duration
The constructors, from the shortest length this representation can name to the longest it should.
micros is the identity, and is here anyway: the representation being microseconds is a fact of
this module rather than of the caller, and a program writing Duration(n) has reached past the
constructors to depend on it. The two short ones are also the only two an embedded program asks
for – a blink loop wants 120 milliseconds, and there is no way to spell that in seconds.
millis
millis(n: long) -> Duration
minute_of
minute_of(t: LocalTime) -> int
minutes
minutes(n: long) -> Duration
month_name
month_name(m: int) -> string
month_of
month_of(d: LocalDate) -> int
odd_hours
odd_hours(d: Duration) -> long
…and the remainders, which are what the parts above leave. odd_minutes of three and a half hours
is thirty, not two hundred and ten.
odd_minutes
odd_minutes(d: Duration) -> long
odd_seconds
odd_seconds(d: Duration) -> long
odd_us
odd_us(d: Duration) -> long
offset_text
offset_text(o: Offset) -> string
+05:30, the way an offset is written everywhere one is written down — a sign, then a width the
reader can rely on, which is what makes a rendered timestamp sortable as text. Z is UTC, and it
is a spelling rather than a special case: an offset of zero is written that way by ISO 8601 and
read back as zero by offset_of below.
on_or_after
on_or_after(d: LocalDate, w: Weekday) -> LocalDate
The first date on or after d falling on the given weekday, which is what “every Tuesday” means
once a rule has been given a starting point.
parse_date
parse_date(s: string) -> Result[LocalDate, TimeParseError]
YYYY-MM-DD. The year is four digits, which is what ISO 8601 says and what date_text writes;
a year outside 0..9999 has no ISO spelling without a sign prefix, and that extension is not here.
parse_datetime
parse_datetime(s: string) -> Result[LocalDateTime, TimeParseError]
A date and a time, joined by a space or a T. Both spellings are read because both are written:
ISO 8601 says T, and datetime_text writes a space because that is what a person reads.
parse_offset
parse_offset(s: string) -> Result[Offset, TimeParseError]
Z, +05:30, -08:00. The Z is zero, which is the spelling offset_text writes it back as.
parse_time
parse_time(s: string) -> Result[LocalTime, TimeParseError]
HH:MM, HH:MM:SS, or HH:MM:SS.ffffff — the seconds and the fraction each optional, on the
same terms time_text writes them.
parse_timestamp
parse_timestamp(s: string) -> Result[Instant, TimeParseError]
A whole timestamp, straight to the point on the timeline it names — which is the thing a program reading a log line, a wire format or a database column actually wants, and the one conversion in this direction that needs no zone table.
It reads more shapes than timestamp_text writes, deliberately. The renderer emits one form
because a wire format with options is a wire format everybody implements differently; the parser
takes the space as well as the T, and a time with no seconds as well as one with them, because
what arrives was written by somebody else. Liberal in what it accepts, strict in what it sends.
plus_days
plus_days(ldt: LocalDateTime, n: int) -> LocalDateTime
Adding days to a wall clock reading is not adding a length of time to it. Seven days later is the same clock face seven rows down the calendar, whatever the timeline did in between — and across a change of a zone’s clocks the two answers differ by an hour. Both are right; they answer different questions, which is why they are spelled differently.
plus_months
plus_months(d: LocalDate, n: int) -> LocalDate
Months are added by the calendar rather than by counting days, so the day of the month is clamped where the target month is shorter: the 31st of January plus one month is the 28th or 29th of February. Every library that offers this makes that choice, and clamping is the one that keeps “the last day of the month” landing on a last day.
plus_years
plus_years(d: LocalDate, n: int) -> LocalDate
resolve
resolve(ldt: LocalDateTime, offset_at: Instant -> Offset) -> Resolution
A wall clock reading resolved against a zone, where a zone is anything able to say what its offset was at a given instant.
The zone arrives as a function rather than as a type, which is what keeps this file free of a
capability. The whole of what resolution needs to know about a zone is that one question, and a
host reading localtime_r, a package talking to an RTC chip and a table written out by hand can
all answer it. sysl.posix.time.from_local is the host’s answer wired to this.
How it decides, and the one assumption in it
A reading taken as though the offset were zero is within fourteen hours of the instant it names, because that is the widest any zone is ever set from UTC. So the offsets a day either side of that guess bracket every transition that could bear on the answer, and there are at most two of them to try.
Each candidate is applied and then checked against itself: an offset that is still in force at
the instant it produces is a real answer, and one that is not is the reading being read with an
offset nobody’s clock was showing. One survivor is Unique, two is Ambiguous, and none is Gap
– the reading fell in the hour the clocks skipped, so no offset can produce it.
The assumption is that a zone does not change its offset twice inside forty-eight hours. No zone in the IANA database ever has. One that did would be resolved against the wrong pair of offsets rather than diagnosed, which is worth writing down because nothing here could detect it.
second_of
second_of(t: LocalTime) -> int
seconds
seconds(n: long) -> Duration
since
since(later: Instant, earlier: Instant) -> Duration
The same difference under a name that says which end is which. later - earlier is right and
earlier - later is just as easy to write, so the named form stays for the call that wants to be
read rather than worked out.
time_at
time_at(h: int, m: int, s: int) -> LocalTime
time_text
time_text(t: LocalTime) -> string
Seconds appear only when there are any. A meeting at half past nine is written 09:30 by every
human being who has ever written one down, and a renderer insisting on 09:30:00 is reporting its
representation rather than its value. The microseconds appear on the same terms, because a
renderer that shows a value to the second and drops what is below it is reporting something the
value does not say.
timestamp_text
timestamp_text(t: Instant, o: Offset) -> string
A timestamp as a machine reads it: 2026-03-08T09:30:00-05:00, or …Z where the offset is zero.
This one is written for RFC 3339 and the renderers above are written for people, which is why
the shapes differ where it would have been easy to share one. The seconds are always present, even
at zero, because RFC 3339 requires them — so datetime_text‘s rule of dropping what a value does
not say is exactly wrong here, where the reader is a parser with a grammar rather than somebody
glancing at a meeting time. The date and the time are joined by T rather than a space for the
same reason, and the offset is written flush against the time rather than after a space.
The fraction still appears only when there is one, because RFC 3339 makes it optional and a
.000000 on every timestamp is six characters of noise on the wire.
us_of
us_of(t: LocalTime) -> long
wall_of
wall_of(us: long) -> LocalDateTime
wall_us
wall_us(ldt: LocalDateTime) -> long
A wall clock reading as a single count, measured from the same origin as an Instant but not
an instant: it is what the count would be if the zone’s offset happened to be zero. A conversion
through a zone starts here and then asks what the offset actually is.
weekday
weekday(d: LocalDate) -> Weekday
1970-01-01 was a Thursday, so shifting by four puts Sunday at zero. Nothing here needs to know which day a week starts on; it only needs the seven in the order the world uses them.
A weekday is a simple enum, so it converts to its number and back, and both directions are used: the arithmetic wants a number to take a remainder of, and everything else wants a name. The remainder is in range by construction, which is what makes the conversion back total rather than something that has to be checked.
weekday_name
weekday_name(w: Weekday) -> string
The day’s name, which is the variant’s own name and so is not written twice. Weekday::Image
answers a simple enum’s variant with the word it is spelled with (reference/attributes.md § A simple enum), so this function is the library’s name for the attribute rather than a table that
could drift from the declaration above it. month_name below is the other kind and stays a
table: a month is an int here, and “January” is a word nothing in the source is already
holding.
whole_days
whole_days(d: Duration) -> long
The parts a duration is read back in, each truncated toward zero. They are how a length of time is stated to somebody who has to check it: “167 hours” is a sentence a reader can hold against a calendar, and 601200000000 is not.
whole_hours
whole_hours(d: Duration) -> long
whole_micros
whole_micros(d: Duration) -> long
whole_millis
whole_millis(d: Duration) -> long
whole_minutes
whole_minutes(d: Duration) -> long
whole_seconds
whole_seconds(d: Duration) -> long
year_of
year_of(d: LocalDate) -> int
Types
Civil
struct Civil
year: int
month: int
day: int
Proleptic Gregorian, which means the Gregorian rules run backwards through the years before anybody agreed to them. That is a decision and not an oversight: the alternative is a calendar with a ten-day hole in it whose position depends on which country you ask.
Duration
struct Duration
us: long
A length of timeline. It is not a number of days, because a day is not a fixed length of timeline — a day that a zone’s clocks move through is 23 or 25 hours long.
Instant
struct Instant
us: long
A point on the timeline, counted from 1970-01-01T00:00:00Z. It names no place and no calendar:
the same Instant is a different wall clock reading in every zone, which is the entire reason it
is a separate type from the civil ones.
LocalDate
struct LocalDate
day: int
Days since 1970-01-01. Signed, so dates before the epoch are ordinary values rather than a special case — the algorithm below never branches on the sign.
LocalDateTime
struct LocalDateTime
date: LocalDate
time: LocalTime
A wall clock reading. Two independent counts rather than one, because they are added to and compared far more often than they are combined.
LocalTime
struct LocalTime
us: long
Microseconds since midnight, from zero up to but not including a whole day.
Offset
struct Offset
minutes: int
How far a zone’s wall clock is set from UTC, in whole minutes. Minutes rather than hours because India is at +05:30 and Nepal at +05:45, and a library that assumes whole hours works everywhere its author has lived.
Resolution
enum Resolution
Unique(at: Instant)
Ambiguous(earlier: Instant, later: Instant)
Gap(before: Offset, after: Offset)
What a wall clock reading turns out to name, once a zone has been asked.
A reading is usually one instant, sometimes none, and sometimes two. That is a property of
zones rather than a shortcoming here: where a zone sets its clocks forward, the hour it skips never
happens, and where it sets them back, the hour it repeats happens twice. Every date-time library
either says so in a return type or picks one silently, and the host’s own answer is the silent
kind – mktime given the repeated reading answers the second occurrence and reports nothing, and
given the skipped one it rewrites the caller’s fields and answers a time nobody asked for.
Gap carries the offsets either side of the transition rather than an instant, because there is no
instant to carry. What to do about it is a policy question – push the reading forward by the size
of the gap, clamp it to the transition, or refuse it – and from_offset(ldt, before) is the first
of those, which is the one most libraries choose.
TimeParseError
enum TimeParseError
BadShape(at: usize)
OutOfRange(what: string)
Trailing(at: usize)
Why a parse refused, at the granularity a caller can act on.
The three are separated by what a caller would do: a shape error is a message about the format, a range error is a message about the value, and a trailing error usually means the text held something more that the caller meant to split off first.
Weekday
enum Weekday
Sunday
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
Traits
DurationUnits
trait DurationUnits
us -> Duration
ms -> Duration
s -> Duration
minutes -> Duration
hours -> Duration
days -> Duration
Durations written the way a datasheet writes them: the number first and the unit after it, so
sleep(5.ms) and join(ssid, pw, auth, 20.s) say at a glance what millis(5) and seconds(20)
say after a moment’s reading.
The short units are symbols and the long ones are words, which is a split about where each is
used rather than a compromise between two styles. A timeout, a poll interval and a debounce are
the sub-second end, they are written constantly, and ms is the spelling every datasheet already
uses. At the other end the number is small and the line is not dense, so 30.days costs nothing
and says more than 30.d would. 5.min is deliberately absent: it would sit beside min(a, b)
and int::Min meaning something else entirely in each position.
| property | the constructor it mirrors |
|---|---|
5.us | micros(5) |
5.ms | millis(5) |
5.s | seconds(5) |
5.minutes | minutes(5) |
5.hours | hours(5) |
5.days | days(5) |
The free constructors stay, and the two spellings are not a duplication to be tidied away: a
duration built from a computed value reads better as millis(n) than as n.millis, and the
constructors are what library/ and sysl-lang/pico2 are written against.
There is no matching set for Instant. A point on the timeline has no natural 5.<unit> — five
of what, from when? — so the only way to name one is still to say which epoch it is counted from.
| Member | Signature | Description |
|---|---|---|
us | us -> Duration | |
ms | ms -> Duration | |
s | s -> Duration | |
minutes | minutes -> Duration | |
hours | hours -> Duration | |
days | days -> Duration |
Implementations
Add for Duration
impl Add for Duration
Add for Instant
impl Add[Duration] for Instant
An instant moved by a length of time. This is the absolute answer: it lands exactly that many microseconds along the timeline whatever any calendar does in between.
Display for Instant
impl Display for Instant
Display for LocalDate
impl Display for LocalDate
Display for LocalDateTime
impl Display for LocalDateTime
Display for LocalTime
impl Display for LocalTime
Display for Offset
impl Display for Offset
Display for Resolution
impl Display for Resolution
The rendering, so that a resolution can be reported without matching on it.
Display for TimeParseError
impl Display for TimeParseError
DurationUnits for T
impl[T: Integer] DurationUnits for T
One block over the whole integer family, which is what Display and Hash already do for the
same reason: u12 and i5 are types a program may name, so no finite list of blocks could cover
them, and a bare literal’s int is only one member of the family it has to cover.
The widening to long is where the representation is met — a Duration counts microseconds in a
long — and it is what lets a u8 receiver work at all.
Eq for Duration
impl Eq for Duration
Eq for Instant
impl Eq for Instant
Eq for LocalDate
impl Eq for LocalDate
Eq for LocalDateTime
impl Eq for LocalDateTime
Eq for LocalTime
impl Eq for LocalTime
Eq for Offset
impl Eq for Offset
Mul for Duration
impl Mul[long] for Duration
Scaling reads one way round only: d * 2 is writable and 2 * d is not, because an operator’s
implementation is written for the type on its left and nothing may be written for long.
Neg for Duration
impl Neg for Duration
Ord for Duration
impl Ord for Duration
Ord for Instant
impl Ord for Instant
Ord for LocalDate
impl Ord for LocalDate
Ord for LocalDateTime
impl Ord for LocalDateTime
Ord for LocalTime
impl Ord for LocalTime
Ord for Offset
impl Ord for Offset
Sub for Duration
impl Sub for Duration
Sub for Instant
impl Sub[Duration] for Instant
Sub for Instant
impl Sub[Instant, Duration] for Instant
The difference of two instants is a duration, and it is the operator. An implementation carries
its result as Out (reference/expressions.md § The operand and the result are both trait arguments), so one type may subtract two different things and answer with a third: the row above
lands further along the timeline, and this one measures the gap between two points on it. What
tells them apart is the type of the right operand and nothing else, which is what makes both of
them ordinary uses of -.