~sircmpwn/hare-dev

hare: time::chrono: fix conv_mtc_tai() v1 PROPOSED

Byron Torres: 6
 time::chrono: fix conv_mtc_tai()
 time::chrono: use temporary offset
 time::chrono: give names to all timezones
 time::chrono,datetime: rename min, sec, nsec
 time::chrono,datetime: embed instant into moment
 time,time::chrono,datetime: rehaul

 41 files changed, 2446 insertions(+), 1844 deletions(-)
#933502 alpine.yml success
#933503 freebsd.yml success
hare/patches: SUCCESS in 1m53s

[time::chrono: fix conv_mtc_tai()][0] from [Byron Torres][1]

[0]: https://lists.sr.ht/~sircmpwn/hare-dev/patches/38675
[1]: mailto:b@torresjrjr.com

✓ #933502 SUCCESS hare/patches/alpine.yml  https://builds.sr.ht/~sircmpwn/job/933502
✓ #933503 SUCCESS hare/patches/freebsd.yml https://builds.sr.ht/~sircmpwn/job/933503
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~sircmpwn/hare-dev/patches/38675/mbox | git am -3
Learn more about email & git

[PATCH hare 1/6] time::chrono: fix conv_mtc_tai() Export this patch

Signed-off-by: Byron Torres <b@torresjrjr.com>
---
 time/chrono/timescale.ha | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/time/chrono/timescale.ha b/time/chrono/timescale.ha
index baa142e7..fdcb1f00 100644
--- a/time/chrono/timescale.ha
+++ b/time/chrono/timescale.ha
@@ -239,7 +239,7 @@ fn conv_mtc_tai(a: time::instant) (time::instant | time::error) = {

	// Get the TAI time.
	// '!' since TT and TAI are continuous.
	const b = tt.from_tai(b)!;
	const b = tt.to_tai(b)!;

	return b;
};
-- 
2.39.1

[PATCH hare 2/6] time::chrono: use temporary offset Export this patch

Signed-off-by: Byron Torres <b@torresjrjr.com>
---
 time/chrono/timescale.ha | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/time/chrono/timescale.ha b/time/chrono/timescale.ha
index fdcb1f00..039db31c 100644
--- a/time/chrono/timescale.ha
+++ b/time/chrono/timescale.ha
@@ -86,7 +86,7 @@ fn conv_utc_tai(a: time::instant) (time::instant | time::error) = {
	};

	const b = time::instant {
		sec = a.sec + ofst,
		sec = a.sec + 37,
		nsec = a.nsec,
	};
	return b;
-- 
2.39.1

[PATCH hare 3/6] time::chrono: give names to all timezones Export this patch

Though [[timezone]].name is is generally used as the filepath where
their corresponding TZif file exists in /usr/share/zoneinfo, it is also
a general identifier.

"TAI", "GPS", "TT", and "MTC" aren't commonly used timezones -- they
don't have corresponding TZif files -- but they are standardized. It
seems sensible to be able to identify them, and use %L with them.

Therefore, the assumption that .name is a filepath should no longer hold
true universally. Perhaps we should introduce two separate fields for
these use cases.

Signed-off-by: Byron Torres <b@torresjrjr.com>
---
 time/chrono/timezone.ha | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/time/chrono/timezone.ha b/time/chrono/timezone.ha
index 04be427d..b244af64 100644
--- a/time/chrono/timezone.ha
+++ b/time/chrono/timezone.ha
@@ -286,7 +286,7 @@ const TZ_UTC: timezone = timezone {
export const TAI: locality = &TZ_TAI;

const TZ_TAI: timezone = timezone {
	name = "",
	name = "TAI",
	timescale = &tai,
	daylength = EARTH_DAY,
	zones = [
@@ -305,7 +305,7 @@ const TZ_TAI: timezone = timezone {
export const GPS: locality = &TZ_GPS;

const TZ_GPS: timezone = timezone {
	name = "",
	name = "GPS",
	timescale = &gps,
	daylength = EARTH_DAY,
	zones = [
@@ -324,7 +324,7 @@ const TZ_GPS: timezone = timezone {
export const TT: locality = &TZ_TT;

const TZ_TT: timezone = timezone {
	name = "",
	name = "TT",
	timescale = &tt,
	daylength = EARTH_DAY,
	zones = [
@@ -343,7 +343,7 @@ const TZ_TT: timezone = timezone {
export const MTC: locality = &TZ_MTC;

const TZ_MTC: timezone = timezone {
	name = "",
	name = "MTC",
	timescale = &mtc,
	daylength = MARS_SOL_MARTIAN,
	zones = [
-- 
2.39.1

[PATCH hare 4/6] time::chrono,datetime: rename min, sec, nsec Export this patch

This is in preparation for the next commit.

* min -> minute
* sec -> second
* nsec -> nanosecond

Signed-off-by: Byron Torres <b@torresjrjr.com>
---
 datetime/arithmetic.ha | 24 ++++++++++-----------
 datetime/chronology.ha | 48 +++++++++++++++++++++---------------------
 datetime/datetime.ha   | 18 ++++++++--------
 datetime/format.ha     |  8 +++----
 datetime/parse.ha      |  6 +++---
 5 files changed, 52 insertions(+), 52 deletions(-)

diff --git a/datetime/arithmetic.ha b/datetime/arithmetic.ha
index 4695decb..0ec721a9 100644
--- a/datetime/arithmetic.ha
+++ b/datetime/arithmetic.ha
@@ -113,19 +113,19 @@ export fn diff(a: datetime, b: datetime) period = {
		res.hours = 24 + res.hours;
	};

	res.minutes = min(&a) - min(&b);
	res.minutes = minute(&a) - minute(&b);
	if (res.minutes < 0) {
		res.hours -= 1;
		res.minutes = 60 + res.minutes;
	};

	res.seconds = sec(&a) - sec(&b);
	res.seconds = second(&a) - second(&b);
	if (res.seconds < 0) {
		res.minutes -= 1;
		res.seconds = 60 + res.seconds;
	};

	res.nanoseconds = nsec(&a) - nsec(&b);
	res.nanoseconds = nanosecond(&a) - nanosecond(&b);
	if (res.nanoseconds < 0) {
		res.seconds -= 1;
		res.nanoseconds = time::SECOND + res.nanoseconds;
@@ -221,12 +221,12 @@ export fn truncate(dt: datetime, u: unit) datetime = {
	case unit::MINUTE =>
		yield new(dt.loc, 0,
			year(&dt), month(&dt), day(&dt),
			hour(&dt), min(&dt), 0, 0,
			hour(&dt), minute(&dt), 0, 0,
		)!;
	case unit::SECOND =>
		yield new(dt.loc, 0,
			year(&dt), month(&dt), day(&dt),
			hour(&dt), min(&dt), sec(&dt), 0,
			hour(&dt), minute(&dt), second(&dt), 0,
		)!;
	case unit::NANOSECOND =>
		yield dt;
@@ -316,9 +316,9 @@ export fn add(dt: datetime, flag: calculus, pp: period...) datetime = {
	let d_month = month(&dt);
	let d_day = day(&dt);
	let d_hour = hour(&dt);
	let d_min = min(&dt);
	let d_sec = sec(&dt);
	let d_nsec = ((nsec(&dt)): i64);
	let d_minute = minute(&dt);
	let d_second = second(&dt);
	let d_nanosecond = ((nanosecond(&dt)): i64);
	for (let i = 0z; i < len(pp); i += 1) {
		const p = pp[i];

@@ -396,14 +396,14 @@ export fn add(dt: datetime, flag: calculus, pp: period...) datetime = {
			};
			const new_hmsn = calc_hmsn(new_time);
			d_hour = new_hmsn.0;
			d_min = new_hmsn.1;
			d_sec = new_hmsn.2;
			d_nsec = new_hmsn.3;
			d_minute = new_hmsn.1;
			d_second = new_hmsn.2;
			d_nanosecond = new_hmsn.3;
		};
	};
	// TODO: Add zoffset back in here once API is settled
	return new(dt.loc, 0,
		d_year, d_month, d_day, d_hour, d_min, d_sec, d_nsec: int,
		d_year, d_month, d_day, d_hour, d_minute, d_second, d_nanosecond: int,
	)!;
};

diff --git a/datetime/chronology.ha b/datetime/chronology.ha
index 1cdb0274..766ccc69 100644
--- a/datetime/chronology.ha
+++ b/datetime/chronology.ha
@@ -50,13 +50,13 @@ export fn isoweek(dt: *datetime) int = _isoweek(dt);
export fn hour(dt: *datetime) int = _hour(dt);

// Returns a [[datetime]]'s minute of the hour.
export fn min(dt: *datetime) int = _min(dt);
export fn minute(dt: *datetime) int = _minute(dt);

// Returns a [[datetime]]'s second of the minute.
export fn sec(dt: *datetime) int = _sec(dt);
export fn second(dt: *datetime) int = _second(dt);

// Returns a [[datetime]]'s nanosecond of the second.
export fn nsec(dt: *datetime) int = _nsec(dt);
export fn nanosecond(dt: *datetime) int = _nanosecond(dt);

fn _epochal(dt: *datetime) chrono::date = {
	const ldt = transform(*dt, dt.zone.zoffset);
@@ -246,55 +246,55 @@ fn _hour(dt: *datetime) int = {
	case void =>
		const hmsn = calc_hmsn(ldt.time: time::duration);
		dt.hour = hmsn.0;
		dt.min = hmsn.1;
		dt.sec = hmsn.2;
		dt.nsec = hmsn.3;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
		dt.nanosecond = hmsn.3;
		return dt.hour: int;
	case let h: int =>
		return h;
	};
};

fn _min(dt: *datetime) int = {
fn _minute(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.min) {
	match (dt.minute) {
	case void =>
		const hmsn = calc_hmsn(ldt.time: time::duration);
		dt.hour = hmsn.0;
		dt.min = hmsn.1;
		dt.sec = hmsn.2;
		dt.nsec = hmsn.3;
		return dt.min: int;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
		dt.nanosecond = hmsn.3;
		return dt.minute: int;
	case let m: int =>
		return m;
	};
};

fn _sec(dt: *datetime) int = {
fn _second(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.sec) {
	match (dt.second) {
	case void =>
		const hmsn = calc_hmsn(ldt.time: time::duration);
		dt.hour = hmsn.0;
		dt.min = hmsn.1;
		dt.sec = hmsn.2;
		dt.nsec = hmsn.3;
		return dt.sec: int;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
		dt.nanosecond = hmsn.3;
		return dt.second: int;
	case let s: int =>
		return s;
	};
};

fn _nsec(dt: *datetime) int = {
fn _nanosecond(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.nsec) {
	match (dt.nanosecond) {
	case void =>
		const hmsn = calc_hmsn(ldt.time: time::duration);
		dt.hour = hmsn.0;
		dt.min = hmsn.1;
		dt.sec = hmsn.2;
		dt.nsec = hmsn.3;
		return dt.nsec: int;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
		dt.nanosecond = hmsn.3;
		return dt.nanosecond: int;
	case let n: int =>
		return n;
	};
diff --git a/datetime/datetime.ha b/datetime/datetime.ha
index b4c08b37..515c2596 100644
--- a/datetime/datetime.ha
+++ b/datetime/datetime.ha
@@ -23,9 +23,9 @@ export type datetime = struct {
	weekday:     (void | int),

	hour:        (void | int),
	min:         (void | int),
	sec:         (void | int),
	nsec:        (void | int),
	minute:      (void | int),
	second:      (void | int),
	nanosecond:  (void | int),
};

fn init() datetime = datetime {
@@ -46,9 +46,9 @@ fn init() datetime = datetime {
	weekday     = void,

	hour        = void,
	min         = void,
	sec         = void,
	nsec        = void,
	minute      = void,
	second      = void,
	nanosecond  = void,
};

// Creates a new datetime. When loc=void, defaults to chrono::local.
@@ -146,9 +146,9 @@ export fn new(
		&& month == _month(&dt)
		&& day == _day(&dt)
		&& hour == _hour(&dt)
		&& min == _min(&dt)
		&& sec == _sec(&dt)
		&& nsec == _nsec(&dt)
		&& min == _minute(&dt)
		&& sec == _second(&dt)
		&& nsec == _nanosecond(&dt)
	) {
		void;
	} else {
diff --git a/datetime/format.ha b/datetime/format.ha
index 2034686d..1ec36194 100644
--- a/datetime/format.ha
+++ b/datetime/format.ha
@@ -122,9 +122,9 @@ fn fmtout(out: io::handle, r: rune, dt: *datetime) (size | io::error) = {
	case 'm' =>
		return fmt::fprintf(out, "{:02}", month(dt));
	case 'M' =>
		return fmt::fprintf(out, "{:02}", min(dt));
		return fmt::fprintf(out, "{:02}", minute(dt));
	case 'N' =>
		return fmt::fprintf(out, "{:09}", strconv::itos(nsec(dt)));
		return fmt::fprintf(out, "{:09}", strconv::itos(nanosecond(dt)));
	case 'p' =>
		const s = if (hour(dt) < 12) {
			yield "AM";
@@ -135,9 +135,9 @@ fn fmtout(out: io::handle, r: rune, dt: *datetime) (size | io::error) = {
	case 's' =>
		return fmt::fprintf(out, "{:02}", epochunix(dt));
	case 'S' =>
		return fmt::fprintf(out, "{:02}", sec(dt));
		return fmt::fprintf(out, "{:02}", second(dt));
	case 'T' =>
		return fmt::fprintf(out, "{:02}:{:02}:{:02}", hour(dt), min(dt), sec(dt));
		return fmt::fprintf(out, "{:02}:{:02}:{:02}", hour(dt), minute(dt), second(dt));
	case 'u' =>
		return fmt::fprint(out, strconv::itos(weekday(dt)));
	case 'U' =>
diff --git a/datetime/parse.ha b/datetime/parse.ha
index 060e25ad..2b9093ae 100644
--- a/datetime/parse.ha
+++ b/datetime/parse.ha
@@ -89,10 +89,10 @@ export fn parse(build: *builder, layout: str, s: str) (void | invalid) = {
			build.month = clamp_int(
				get_max_n_digits(&s_iter, 2)?, 1, 12);
		case 'M' =>
			build.min = clamp_int(
			build.minute = clamp_int(
				get_max_n_digits(&s_iter, 2)?, 0, 59);
		case 'N' =>
			build.nsec = clamp_int(
			build.nanosecond = clamp_int(
				get_max_n_digits(&s_iter, 9)?, 0, 999999999);
		case 'p' =>
			if (build.hour is void) {
@@ -122,7 +122,7 @@ export fn parse(build: *builder, layout: str, s: str) (void | invalid) = {
			strings::next(&s_iter);
			strings::next(&s_iter);
		case 'S' =>
			build.sec = clamp_int(
			build.second = clamp_int(
				get_max_n_digits(&s_iter, 2)?, 0, 61);
		case 'u', 'w' =>
			build.weekday = match (get_max_n_digits(&s_iter, 1)) {
-- 
2.39.1

[PATCH hare 5/6] time::chrono,datetime: embed instant into moment Export this patch

This commit facilitates a system of unification of all temporal types.
The time::chrono::moment type now:

* Embeds the time::instant struct type.
* Uses voidable .date, .time, and .zone fields.

Datetimes and moments are now intrinsically instants and inherit the
same properties of precision and ubiquity. Pointers to such objects may
be passed around seemlessly.

	*(&dt: *time::chrono::moment)
	*(&dt: *time::instant)

The .date and .time fields now hold observed values instead of
normalized values, and are to be evalutated and accessed with the new
exported functions listed below. The dangerous transform() function is
no longer needed.

New exports:

* fn time::chrono::getzone()
* fn time::chrono::getdate()
* fn time::chrono::gettime()
* fn time::chrono::from_datetime()

Removed exports:

* fn time::chrono::from_instant() // use chrono::new()
* fn time::chrono::to_instant()   // use *(&m: *time::instant)
* fn datetime::to_instant()       // use *(&dt: *time::instant)
* fn datetime::to_moment()        // use *(&dt: *time::chrono::moment)
* fn datetime::lookupzone()       // use chrono::getzone()
* fn time::chrono::transform()    // time::chrono now handles this
* fn datetime::transform()        // time::chrono now handles this

Signed-off-by: Byron Torres <b@torresjrjr.com>
---
 datetime/arithmetic.ha    |  83 +++-------------------
 datetime/chronology.ha    |  29 +++-----
 datetime/datetime.ha      |  70 +++++++------------
 datetime/format.ha        |   8 +--
 datetime/parse.ha         |   9 +--
 datetime/timezone.ha      |  17 +----
 time/chrono/chronology.ha | 141 ++++++++++++++++++++++++++++----------
 time/chrono/timezone.ha   |  35 +++-------
 8 files changed, 168 insertions(+), 224 deletions(-)

diff --git a/datetime/arithmetic.ha b/datetime/arithmetic.ha
index 0ec721a9..8ced8d59 100644
--- a/datetime/arithmetic.ha
+++ b/datetime/arithmetic.ha
@@ -53,7 +53,7 @@ export type unit = enum int {
// Equivalence means they represent the same moment in time, regardless of their
// locality or observed chronological values.
export fn eq(a: datetime, b: datetime) bool = {
	return a.date == b.date && a.time == b.time;
	return time::compare(*(&a: *time::instant), *(&b: *time::instant)) == 0;
};

// Returns true if [[datetime]] "a" succeeds [[datetime]] "b".
@@ -61,8 +61,7 @@ export fn eq(a: datetime, b: datetime) bool = {
// Temporal order is evaluated in a universal frame of reference, regardless of
// their locality or observed chronological values.
export fn after(a: datetime, b: datetime) bool = {
	return !eq(a, b) &&
		(a.date > b.date || a.date == b.date && a.time > b.time);
	return time::compare(*(&a: *time::instant), *(&b: *time::instant)) == +1;
};

// Returns true if [[datetime]] "a" precedes [[datetime]] "b".
@@ -70,7 +69,7 @@ export fn after(a: datetime, b: datetime) bool = {
// Temporal order is evaluated in a universal frame of reference, regardless of
// their locality or observed chronological values.
export fn before(a: datetime, b: datetime) bool = {
	return !eq(a, b) && !after(a, b);
	return time::compare(*(&a: *time::instant), *(&b: *time::instant)) == -1;
};

// Calculates the [[period]] between two [[datetime]]s.
@@ -148,7 +147,7 @@ export fn unitdiff(a: datetime, b: datetime, u: unit) i64 = {
	case unit::WEEK =>
		yield unitdiff(a, b, unit::DAY) / 7;
	case unit::DAY =>
		yield math::absi(a.date - b.date): int;
		yield math::absi(chrono::getdate(&a) - chrono::getdate(&b)): int;
	case unit::HOUR =>
		const full_diff = diff(a, b);
		yield (unitdiff(a, b, unit::DAY) * 24) + full_diff.hours;
@@ -202,7 +201,7 @@ export fn truncate(dt: datetime, u: unit) datetime = {
			0, 0, 0, 0,
		)!;
	case unit::WEEK =>
		const date = dt.date - (weekday(&dt) - 1);
		const date = chrono::getdate(&dt) - (weekday(&dt) - 1);
		const ymd = calc_ymd(date);
		yield new(dt.loc, 0,
			ymd.0, ymd.1, ymd.2,
@@ -322,7 +321,7 @@ export fn add(dt: datetime, flag: calculus, pp: period...) datetime = {
	for (let i = 0z; i < len(pp); i += 1) {
		const p = pp[i];

		let latest_date = dt.date;
		let latest_date = chrono::getdate(&dt);

		if (p.years != 0) {
			d_year += p.years;
@@ -346,8 +345,7 @@ export fn add(dt: datetime, flag: calculus, pp: period...) datetime = {
		if (p.weeks != 0) {
			p.days += p.weeks * 7;
		};
		latest_date = calc_date_from_ymd(
			d_year, d_month, d_day)!;
		latest_date = calc_date_from_ymd(d_year, d_month, d_day)!;
		if (p.days != 0) {
			const new_ymd = calc_ymd(latest_date + p.days);
			d_year = new_ymd.0;
@@ -376,7 +374,7 @@ export fn add(dt: datetime, flag: calculus, pp: period...) datetime = {
				p.nanoseconds %= ns_in_day;
			};

			let new_time = dt.time + p.nanoseconds;
			let new_time = chrono::gettime(&dt) + p.nanoseconds;

			if (new_time >= ns_in_day) {
				overflowed_days += 1;
@@ -387,8 +385,7 @@ export fn add(dt: datetime, flag: calculus, pp: period...) datetime = {
			};

			if (overflowed_days != 0) {
				const new_date = latest_date +
					overflowed_days;
				const new_date = latest_date + overflowed_days;
				const new_ymd = calc_ymd(new_date);
				d_year = new_ymd.0;
				d_month = new_ymd.1;
@@ -432,68 +429,6 @@ export fn sub(dt: datetime, flag: calculus, pp: period...) datetime = {
	return add(dt, flag, pp...);
};

@test fn eq() void = {
	const dt = new(chrono::UTC, 0, 2022, 2, 4, 3, 14, 7, 0)!;
	const cases = [
		((-768, 1,  1,  3, 14,  7,    0), false),
		((   1, 1,  1, 14,  0,  0, 1234), false),
		((2022, 2,  4,  3, 14,  7,    0), true),
		((2022, 2,  4,  3, 14,  7,    1), false),
		((2038, 1, 19,  3, 14,  7,    0), false),
		((5555, 5,  5,  5, 55, 55, 5555), false),
	];
	for (let i = 0z; i < len(cases); i += 1) {
		const c = cases[i].0;
		const expected = cases[i].1;
		const case_dt = new(chrono::UTC, 0,
			c.0, c.1, c.2, c.3, c.4, c.5, c.6)!;
		assert(eq(dt, case_dt) == expected,
			"equality comparison failed");
	};
};

@test fn after() void = {
	const dt = new(chrono::UTC, 0, 2022, 2, 4, 3, 14, 7, 0)!;
	const cases = [
		((-768, 1,  1,  3, 14,  7,    0), false),
		((   1, 1,  1, 14,  0,  0, 1234), false),
		((2020, 2,  4,  3, 14,  7,    1), false),
		((2022, 2,  4,  3, 14,  7,    0), false),
		((2022, 2,  4,  4,  1,  1,    0), true),
		((2038, 1, 19,  3, 14,  7,    0), true),
		((5555, 5,  5,  5, 55, 55, 5555), true),
	];
	for (let i = 0z; i < len(cases); i += 1) {
		const c = cases[i].0;
		const expected = cases[i].1;
		const case_dt = new(chrono::UTC, 0,
			c.0, c.1, c.2, c.3, c.4, c.5, c.6)!;
		assert(after(case_dt, dt) == expected,
			"incorrect date ordering in after()");
	};
};

@test fn before() void = {
	const dt = new(chrono::UTC, 0, 2022, 2, 4, 3, 14, 7, 0)!;
	const cases = [
		((-768, 1,  1,  3, 14,  7,    0), true),
		((   1, 1,  1, 14,  0,  0, 1234), true),
		((2020, 2,  4,  3, 14,  7,    1), true),
		((2022, 2,  4,  3, 14,  7,    0), false),
		((2022, 2,  4,  4,  1,  1,    0), false),
		((2038, 1, 19,  3, 14,  7,    0), false),
		((5555, 5,  5,  5, 55, 55, 5555), false),
	];
	for (let i = 0z; i < len(cases); i += 1) {
		const c = cases[i].0;
		const expected = cases[i].1;
		const case_dt = new(chrono::UTC, 0,
			c.0, c.1, c.2, c.3, c.4, c.5, c.6)!;
		assert(before(case_dt, dt) == expected,
			"incorrect date ordering in before()");
	};
};

@test fn diff() void = {
	const cases = [
		(
diff --git a/datetime/chronology.ha b/datetime/chronology.ha
index 766ccc69..230ef414 100644
--- a/datetime/chronology.ha
+++ b/datetime/chronology.ha
@@ -59,12 +59,11 @@ export fn second(dt: *datetime) int = _second(dt);
export fn nanosecond(dt: *datetime) int = _nanosecond(dt);

fn _epochal(dt: *datetime) chrono::date = {
	const ldt = transform(*dt, dt.zone.zoffset);
	return ldt.date - EPOCHAL_GREGORIAN;
	return chrono::getdate(dt) - EPOCHAL_GREGORIAN;
};

fn _epochunix(dt: *datetime) int = {
	return to_instant(*dt).sec: int;
	return time::unix(*(dt: *time::instant)): int;
};

fn _era(dt: *datetime) int = {
@@ -81,10 +80,9 @@ fn _era(dt: *datetime) int = {
};

fn _year(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.year) {
	case void =>
		const ymd = calc_ymd(ldt.date: chrono::date);
		const ymd = calc_ymd(chrono::getdate(dt));
		dt.year = ymd.0;
		dt.month = ymd.1;
		dt.day = ymd.2;
@@ -95,10 +93,9 @@ fn _year(dt: *datetime) int = {
};

fn _month(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.month) {
	case void =>
		const ymd = calc_ymd(ldt.date: chrono::date);
		const ymd = calc_ymd(chrono::getdate(dt));
		dt.year = ymd.0;
		dt.month = ymd.1;
		dt.day = ymd.2;
@@ -109,10 +106,9 @@ fn _month(dt: *datetime) int = {
};

fn _day(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.day) {
	case void =>
		const ymd = calc_ymd(ldt.date: chrono::date);
		const ymd = calc_ymd(chrono::getdate(dt));
		dt.year = ymd.0;
		dt.month = ymd.1;
		dt.day = ymd.2;
@@ -123,10 +119,9 @@ fn _day(dt: *datetime) int = {
};

fn _weekday(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.weekday) {
	case void =>
		dt.weekday = calc_weekday(ldt.date: chrono::date);
		dt.weekday = calc_weekday(chrono::getdate(dt));
		return dt.weekday: int;
	case let y: int =>
		return y;
@@ -241,10 +236,9 @@ fn _isoweek(dt: *datetime) int = {
};

fn _hour(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.hour) {
	case void =>
		const hmsn = calc_hmsn(ldt.time: time::duration);
		const hmsn = calc_hmsn(chrono::gettime(dt));
		dt.hour = hmsn.0;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
@@ -256,10 +250,9 @@ fn _hour(dt: *datetime) int = {
};

fn _minute(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.minute) {
	case void =>
		const hmsn = calc_hmsn(ldt.time: time::duration);
		const hmsn = calc_hmsn(chrono::gettime(dt));
		dt.hour = hmsn.0;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
@@ -271,10 +264,9 @@ fn _minute(dt: *datetime) int = {
};

fn _second(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.second) {
	case void =>
		const hmsn = calc_hmsn(ldt.time: time::duration);
		const hmsn = calc_hmsn(chrono::gettime(dt));
		dt.hour = hmsn.0;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
@@ -286,10 +278,9 @@ fn _second(dt: *datetime) int = {
};

fn _nanosecond(dt: *datetime) int = {
	const ldt = transform(*dt, dt.zone.zoffset);
	match (dt.nanosecond) {
	case void =>
		const hmsn = calc_hmsn(ldt.time: time::duration);
		const hmsn = calc_hmsn(chrono::gettime(dt));
		dt.hour = hmsn.0;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
diff --git a/datetime/datetime.ha b/datetime/datetime.ha
index 515c2596..dddc5981 100644
--- a/datetime/datetime.ha
+++ b/datetime/datetime.ha
@@ -29,10 +29,12 @@ export type datetime = struct {
};

fn init() datetime = datetime {
	date        = 0,
	time        = 0,
	loc         = chrono::LOCAL,
	zone        = chrono::zone { ... },
	sec         = 0,
	nsec        = 0,
	date        = void,
	time        = void,
	zone        = void,

	era         = void,
	year        = void,
@@ -85,7 +87,7 @@ fn init() datetime = datetime {
//   as the clock jumped back 1 hour from 03:00 CEST to 02:00 CET.
export fn new(
	loc: chrono::locality,
	offs: (time::duration | void),
	zo: (time::duration | void),
	fields: int...
) (datetime | invalid) = {
	// TODO:
@@ -115,29 +117,20 @@ export fn new(
	const sec   = defaults[5];
	const nsec  = defaults[6];

	let m = chrono::moment {
		date = calc_date_from_ymd(year, month, day)?,
		time = calc_time_from_hmsn(hour, min, sec, nsec)?,
		loc = loc,
		zone = chrono::zone { ... },
	};
	const mdate = calc_date_from_ymd(year, month, day)?;
	const mtime = calc_time_from_hmsn(hour, min, sec, nsec)?;

	// TODO: Set the correct values according to the given zo and
	// locality/timezone.
	//
	// figuring out what zone this moment observes
	if (offs is time::duration) {
		// Transform inversely to the moment that would transform back
		// to the current moment, then perform a zone lookup.
		m = chrono::transform(m, -(offs as time::duration));
		chrono::lookupzone(&m);
	} else {
		// Just perform a zone lookup, then try that zone and the
		// zones that are observed before and after. This requires
		// knowlegde of the transition index.
		//const z0 = chrono::lookupzone(*m);
		//m = chrono::transform(m, -z0.zoffset);
		abort("TODO"); // TODO
	// create the moment
	const m = match (zo) {
	case let zo: time::duration =>
		yield chrono::from_datetime(loc, zo, mdate, mtime);
	case void =>
		// TODO: Deduce the zone offset
		//
		// perform a zone lookup, then try that zone and the zones that
		// are observed before and after. This requires knowlegde of the
		// transition index.
		abort("TODO: datetime::new(zo=void)");
	};

	const dt = from_moment(m);
@@ -166,29 +159,26 @@ export fn now() datetime = {
	//
	// https://todo.sr.ht/~sircmpwn/hare/645
	const i = time::now(time::clock::REALTIME);
	const m = chrono::from_instant(i, chrono::LOCAL);
	const m = chrono::new(chrono::LOCAL, i);
	return from_moment(m);
};

// Creates a [[datetime]] from a [[time::chrono::moment]].
export fn from_moment(m: chrono::moment) datetime = {
	const dt = init();
	dt.loc = m.loc;
	dt.sec = m.sec;
	dt.nsec = m.nsec;
	dt.date = m.date;
	dt.time = m.time;
	dt.loc = m.loc;
	dt.zone = m.zone;
	return dt;
};

// Creates a [[datetime]] from a [[time::instant]]
// in a [[time::chrono::locality]].
export fn from_instant(i: time::instant, loc: chrono::locality) datetime = {
	return from_moment(chrono::from_instant(i, loc));
};

// Creates a [[time::instant]] from a [[datetime]].
export fn to_instant(dt: datetime) time::instant = {
	return chrono::to_instant(to_moment(dt));
export fn from_instant(loc: chrono::locality, i: time::instant) datetime = {
	return from_moment(chrono::new(loc, i));
};

// Creates a [[datetime]] from a string, parsed according to a layout,
@@ -200,16 +190,6 @@ export fn from_str(layout: str, s: str) (datetime | insufficient | invalid) = {
	return finish(&b)?;
};

// Creates a [[time::chrono::moment]] from a [[datetime]].
export fn to_moment(dt: datetime) chrono::moment = {
	return chrono::moment {
		date = dt.date,
		time = dt.time,
		loc = dt.loc,
		zone = dt.zone,
	};
};

// A [[builder]] has insufficient information and cannot create a valid datetime.
export type insufficient = !void;

diff --git a/datetime/format.ha b/datetime/format.ha
index 1ec36194..93645ef0 100644
--- a/datetime/format.ha
+++ b/datetime/format.ha
@@ -155,15 +155,15 @@ fn fmtout(out: io::handle, r: rune, dt: *datetime) (size | io::error) = {
	case 'z' =>
		// TODO: test me
		let pm = '+';
		const z = if (dt.zone.zoffset >= 0) {
			yield calc_hmsn(dt.zone.zoffset);
		const z = if (chrono::getzone(dt).zoffset >= 0) {
			yield calc_hmsn(chrono::getzone(dt).zoffset);
		} else {
			pm = '-';
			yield calc_hmsn(-dt.zone.zoffset);
			yield calc_hmsn(-chrono::getzone(dt).zoffset);
		};
		return fmt::fprintf(out, "{}{:02}{:02}", pm, z.0, z.1);
	case 'Z' =>
		return fmt::fprint(out, dt.zone.abbr);
		return fmt::fprint(out, chrono::getzone(dt).abbr);
	case '%' =>
		return fmt::fprint(out, "%");
	case =>
diff --git a/datetime/parse.ha b/datetime/parse.ha
index 2b9093ae..0a0d45e5 100644
--- a/datetime/parse.ha
+++ b/datetime/parse.ha
@@ -5,6 +5,7 @@
use errors;
use strings;
use time;
use time::chrono;

// Parses a date/time string into a [[builder]], according to a layout format
// string with specifiers as documented under [[format]]. Partial, sequential,
@@ -147,20 +148,20 @@ export fn parse(build: *builder, layout: str, s: str) (void | invalid) = {
		case 'z' =>
			const rest = strings::iterstr(&s_iter);
			if(strings::hasprefix(rest, 'Z') || strings::hasprefix(rest, 'z')) {
				build.zone.zoffset = 0;
				(build.zone: chrono::zone).zoffset = 0;
			} else {
				const prefix = strings::next(&s_iter);
				build.zone.zoffset = get_max_n_digits(&s_iter, 2)? * time::HOUR;
				(build.zone: chrono::zone).zoffset = get_max_n_digits(&s_iter, 2)? * time::HOUR;

				const rest = strings::iterstr(&s_iter);
				if(strings::hasprefix(rest, ":")) {
					strings::next(&s_iter);
				};

				build.zone.zoffset += get_max_n_digits(&s_iter, 2)? * time::MINUTE;
				(build.zone: chrono::zone).zoffset += get_max_n_digits(&s_iter, 2)? * time::MINUTE;

				if(prefix == '-') {
					build.zone.zoffset *= -1;
					(build.zone: chrono::zone).zoffset *= -1;
				};
			};
		case '%' =>
diff --git a/datetime/timezone.ha b/datetime/timezone.ha
index 34b8fd3f..a1503650 100644
--- a/datetime/timezone.ha
+++ b/datetime/timezone.ha
@@ -7,20 +7,5 @@ use time::chrono;
// Creates an equivalent [[datetime]] with a different
// [[time::chrono::locality]].
export fn in(loc: chrono::locality, dt: datetime) datetime = {
	const old = to_moment(dt);
	const new = chrono::in(loc, old);
	const new_dt = from_moment(new);
	return new_dt;
};

// Finds, sets and returns a [[datetime]]'s currently observed zone.
export fn lookupzone(dt: *datetime) chrono::zone = {
	const m = to_moment(*dt);
	const z = chrono::lookupzone(&m);
	dt.zone = z;
	return z;
};

export fn transform(dt: datetime, zo: time::duration) datetime = {
	return from_moment(chrono::transform(to_moment(dt), zo));
	return from_moment(chrono::in(loc, *(&dt: *chrono::moment)));
};
diff --git a/time/chrono/chronology.ha b/time/chrono/chronology.ha
index eac10e6c..2a80547b 100644
--- a/time/chrono/chronology.ha
+++ b/time/chrono/chronology.ha
@@ -1,24 +1,40 @@
// License: MPL-2.0
// (c) 2021-2022 Byron Torres <b@torresjrjr.com>
use time;
use math;

// Invalid [[moment]].
export type invalid = !void;

// A moment in time, within a [[locality]], interpreted via a chronology.
// A moment in time within a [[locality]]. Create one with [[new]].
//
// Moments extend the [[time::instant]] type and couples it with a [[timescale]]
// via the .loc field.
//
// Moments observe a [[date]], time-of-day, and [[zone]], which are evaluated
// and accessed by the [[getdate]], [[gettime]], and [[getzone]] functions.
//
// The [[time::chrono]] modules implements a small chronology of dates & times.
// Higher level modules like [[datetime]] expand upon this with more complex
// chronological values (years, hours, etc.). The [[datetime::datetime]] type
// embeds this type, and other modules implementing other chronologies may
// interoperate by passing pointers.
export type moment = struct {
	// The ordinal day (on Earth or otherwise)
	// since the Hare epoch (zeroth day) 1970-01-01
	date: date,
	// The embedded [[time::instant]] of this moment
	time::instant,

	// The time since the start of the day
	time: time::duration,

	// The timezone used for interpreting a moment's date and time
	// The [[locality]] with which to interpret this moment
	loc: locality,

	// The current [[zone]] this moment observes
	zone: zone,
	// The observed ordinal day (on Earth or otherwise)
	// since an abitrary epoch, like the Hare epoch 1970-01-01
	date: (date | void),

	// The observed time since the start of the day
	time: (time::duration | void),

	// The observed [[zone]]
	zone: (zone | void),
};

// An ordinal day since an epoch. The Hare epoch (zeroth day) 1970 Jan 1st is
@@ -26,40 +42,95 @@ export type moment = struct {
export type date = i64;

// Creates a new [[moment]].
export fn new(
export fn new(loc: locality, inst: time::instant) moment = {
	return moment {
		loc = loc,
		sec = inst.sec,
		nsec = inst.nsec,
		date = void,
		time = void,
		zone = void,
	};
};

// Evalutes, caches, and returns a [[moment]]'s observed [[zone]].
export fn getzone(m: *moment) zone = {
	match (m.zone) {
	case let z: zone =>
		return z;
	case void =>
		return lookupzone(m);
	};
};

// Evaluates, caches, and returns a [[moment]]'s observed epochal date.
export fn getdate(m: *moment) date = {
	match (m.date) {
	case let d: date =>
		return d;
	case void =>
		return eval_datetime(m).0;
	};
};

// Evaluates, caches, and returns a [[moment]]'s observed time-of-day as a
// [[time::duration]] since the start of a day.
export fn gettime(m: *moment) time::duration = {
	match (m.time) {
	case let t: time::duration =>
		return t;
	case void =>
		return eval_datetime(m).1;
	};
};

// Evaluates, caches, and returns a [[moment]]'s observed date & time.
fn eval_datetime(m: *moment) (date, time::duration) = {
	const i = time::add(*(m: *time::instant), getzone(m).zoffset);
	const day = m.loc.daylength;
	const daysec = day / time::SECOND;
	const d = if (i.sec >= 0) i.sec / daysec else (i.sec + 1) / daysec - 1;
	const t = ((i.sec % daysec + daysec) * time::SECOND + i.nsec) % day;
	m.time = t;
	m.date = d;
	return (d, t);
};

// Creates a [[moment]] from a given [[locality]], zone offset, [[date]] and
// time-of-day.
export fn from_datetime(
	loc: locality,
	zo: time::duration,
	d: date,
	t: time::duration,
) (moment | invalid) = {
	if (t > loc.daylength) {
		return invalid;
	};
	const m = moment {
) moment = {
	const inst = calc_instant(loc.daylength, zo, d, t);
	return moment {
		loc = loc,
		sec = inst.sec,
		nsec = inst.nsec,
		date = d,
		time = t,
		loc = loc,
		zone = zone { ... },
		zone = void
	};
	lookupzone(&m);
	return m;
};

// Creates a new [[moment]] from a [[time::instant]] in a [[locality]].
export fn from_instant(i: time::instant, loc: locality) moment = {
	const daysec = (loc.daylength / time::SECOND);
	const d = i.sec / daysec;
	const t = (i.sec % daysec) * time::SECOND + i.nsec * time::NANOSECOND;
	assert(t < loc.daylength, "Internal error: time excedes daylength");
	return new(loc, d, t)!;
};

// Creates a new [[time::instant]] from a [[moment]].
export fn to_instant(m: moment) time::instant = {
	const daysec = (m.loc.daylength / time::SECOND);
	const i = time::instant {
		sec = (m.date: i64 * daysec) + (m.time / time::SECOND),
		nsec = m.time % time::SECOND,
fn calc_instant(
	day: time::duration, // length of a day
	zo: time::duration,  // zone offset
	d: date,             // date since epoch
	t: time::duration,   // time since start of day
) time::instant = {
	// TODO: make sure this works across transitions
	const daysec = (day / time::SECOND): i64;
	const dayrem = day % time::SECOND;
	let i = time::instant {
		sec = d * daysec,
		nsec = 0,
	};
	i = time::add(i, d: i64 * dayrem);
	i = time::add(i, t);
	i = time::add(i, -zo);
	return i;
};

diff --git a/time/chrono/timezone.ha b/time/chrono/timezone.ha
index b244af64..1dbf077c 100644
--- a/time/chrono/timezone.ha
+++ b/time/chrono/timezone.ha
@@ -78,7 +78,7 @@ type tzname = struct {
// timescales.
export fn in(loc: locality, m: moment) moment = {
	if (m.loc.timescale != loc.timescale) {
		const i = to_instant(m);
		const i = *(&m: *time::instant);
		const i = match (m.loc.timescale.to_tai(i)) {
		case let i: time::instant =>
			yield i;
@@ -91,26 +91,9 @@ export fn in(loc: locality, m: moment) moment = {
		case time::error =>
			abort("time::chrono::in(): direct timescale conversion failed");
		};
		const m = from_instant(i, loc);
		return m;
		return new(loc, i);
	};

	assert(m.time < loc.daylength, "Internal error: time excedes daylength");
	return new(loc, m.date, m.time)!; // resets .zone
};

export fn transform(m: moment, zo: time::duration) moment = {
	const daylen = m.loc.daylength;

	const t = m.time + zo;
	const mtime = (if (t >= 0) t else t + daylen) % daylen;

	const d = (t / daylen): int;
	const mdate = m.date + (if (t >= 0) d else d - 1);

	m.time = mtime;
	m.date = mdate;
	return m;
	return new(loc, *(&m: *time::instant));
};

// Finds, sets and returns a [[moment]]'s currently observed zone.
@@ -118,19 +101,17 @@ export fn lookupzone(m: *moment) zone = {
	// TODO: https://todo.sr.ht/~sircmpwn/hare/643
	if (len(m.loc.zones) == 0) {
		// TODO: what to do? not ideal to assume UTC
		abort("lookup(): timezones should have at least one zone");
		abort("lookupzone(): timezones should have at least one zone");
	};

	if (len(m.loc.zones) == 1) {
		m.zone = m.loc.zones[0];
		return m.zone;
		return m.zone as zone;
	};

	const inst = to_instant(*m);

	if (
		len(m.loc.transitions) == 0
		|| time::compare(inst, m.loc.transitions[0].when) == -1
		|| time::compare(*(m: *time::instant), m.loc.transitions[0].when) == -1
	) {
		// TODO: special case
		abort("lookupzone(): time is before known transitions");
@@ -141,7 +122,7 @@ export fn lookupzone(m: *moment) zone = {
	for (hi - lo > 1) {
		const mid = lo + (hi - lo) / 2;
		const middle = m.loc.transitions[mid].when;
		switch (time::compare(inst, middle)) {
		switch (time::compare(*(m: *time::instant), middle)) {
		case -1 =>
			hi = mid;
		case 0 =>
@@ -163,7 +144,7 @@ export fn lookupzone(m: *moment) zone = {
		void;
	};

	return m.zone;
	return m.zone as zone;
};

// Creates a [[timezone]] with a single [[zone]]. Useful for fixed offsets.
-- 
2.39.1

[PATCH hare 6/6] time,time::chrono,datetime: rehaul Export this patch

Herein lies significant changes to the date/time modules, along with
various bug fixes, tests, docs, and other auxiliary, minor improvements.

THE TIME MODULE

    The following are removed:

    - type ambiguous = ![]instant;
    - type nonexistent = !void;
    - type error = !(ambiguous | nonexistent);

    Their purpose was to provide the semantics for timescale conversions
    in the [[time::chrono]] module. They are replaced with new types
    which live there instead.

THE TIME::CHRONO MODULE

    The [[timescale]] interface is rehauled. New types replace the
    [[time::error]] types.

    - type analytical = ![]time::instant;
    - type discontinuity = !void;

    The [[timescale]] interface now uses the newly formed
    [[tsconverter]] type. [[ts_converter]] is removed. A new, handy
    [[convert]] function handles user called conversions neatly with
    error handling, avoiding the awkward manual two-step
    to_tai()/from_tai() process.

    The [[moment]].zone field's type has changed to improve memory
    usage.

    - Was:   zone: (void | zone),
    - Now:   zone: nullable *zone,

    The redundant [[lookupzone]] was removed. The following are renamed:

    - [[getdate]] -> [[date]]
    - [[gettime]] -> [[time]]
    - [[getzone]] -> [[mzone]]

    The previous [[date]] type (the i64 alias) was removed.

    New [[compare]], [[eq]], [[add]], and [[diff]] functions are added.
    They are designed to be interoperable with [[datetime::datetime]]
    and other such temporal types.

    New [[timezone_free]] and [[zone_finish]] functions are added.

THE DATETIME MODULE

Virtuals

    A new, more generic "virtual/real" analogy is adopted for
    intermediary datetime representations. This is akin to similar
    naming schemes found in other languages and libraries, such as
    "aware/naive", "floating/zoned", "mutable/immutable", etc.

    The following have been renamed:

    - [[builder]] -> [[virtual]]
    - [[newbuilder]] -> [[newvirtual]]
    - [[finish]] -> [[realize]]

    The virtual interface is now a struct which embeds the [[datetime]]
    type. Its new fields are used appropriately throughout the module
    (namely in [[realize]], [[parse]], [[reckon]]).

    The [[strategy]] enum was removed in favour of a simpler,
    predictable algorithm, documented in [[realize]].

    [[realize]] now handles localities and zone offsets, and has a new
    variadic localities parameter, which solves the problem of
    troublesome [[timezone]] allocations.

Periods

    The new [[sum]], [[neg]], [[abs]] functions handle vector math
    operations on [[period]]. [[period_ed]] is renamed to [[peq]].

    A [[period]]'s fields are now of type i64 instead of int.

Arithmetic

    Datetime arithmetic is categorized into timescale-wise (using
    [[time::duration]]) and chronology-wise (using [[period]]).

    The [[add]] function is transformed into a timescale-wise arithmetic
    operation and accepts [[time::duration]].

    The new [[reckon]] function provides a generic chronology-wise
    arithmetic operation, coupled with the now expanded [[calculus]]
    enum.

    The "reckon" verb is used to disassociate the simple "linear math"
    expectations that the "add" verb brings, from what is essentially a
    unique complex modulus vector math. The analogy of a reckoner,
    reckoning through a chronology, like a journey, is used.

    The "add" verb is reserved for [[time::duration]] by convention, by
    the [[time]] and [[time::chrono]] modules, and Hare expects
    third-party datetime libraries to adopt the same convention.

    The [[hop]] function is removed. It does not prove useful enough
    with the advent of [[reckon]] and [[truncate]].

    The chronology-wise [[diff]] function has been renamed to [[pdiff]].
    The [[time::chrono::diff]] function should be used for a
    timescale-wise operation.

Parsing & formatting

    The parsing code is greatly improved, with many bug fixes.

    The new, rudimentary [[parsefail]] error type is added, and returned
    by [[parse]].

    The [[from_str]] function now accepts a variadic localities
    parameter, in accordance with the change to [[realize]].

Miscellaneous

    The [[epochal]] function is removed in favour of:

        time::chrono::date(&dt) - datetime::EPOCHAL_GERGORIAN;
        time::chrono::date(&dt) - datetime::EPOCHAL_JULIAN;

    The [[epochunix]] function removed in favour of:

        time::unix(*(&dt: *time::instant));
        dt.sec;

    [[is_leap_year]] is renamed to [[isleapyear]].

    The new [[STAMP_ZOFF]] layout is added.

    The [[now]] function now just uses [[UTC]].

    The [[weekday]] function now returns the range Monday=0 to Sunday=6.

    The new [[error]] type and [[strerror]] function are added.

Signed-off-by: Byron Torres <b@torresjrjr.com>
---
 datetime/README           |  46 +--
 datetime/arithmetic.ha    | 744 ++++++++------------------------------
 datetime/chronology.ha    |  35 +-
 datetime/date.ha          | 384 +++++++++-----------
 datetime/datetime.ha      | 255 ++++++-------
 datetime/duration.ha      |  11 +
 datetime/errors.ha        |  18 +
 datetime/format.ha        | 233 ++----------
 datetime/parse.ha         | 506 +++++++++++++++++++-------
 datetime/period.ha        |  69 ++++
 datetime/reckon.ha        | 489 +++++++++++++++++++++++++
 datetime/time.ha          |   2 +-
 datetime/timezone.ha      |   6 +-
 datetime/virtual.ha       | 225 ++++++++++++
 scripts/gen-stdlib        |  14 +-
 stdlib.mk                 |  28 +-
 time/chrono/README        |  29 +-
 time/chrono/arithmetic.ha |  76 ++++
 time/chrono/chronology.ha | 101 +++---
 time/chrono/error.ha      |  12 +-
 time/chrono/leapsec.ha    |   5 +-
 time/chrono/timescale.ha  | 303 +++++++++++-----
 time/chrono/timezone.ha   | 142 ++++----
 time/chrono/tzdb.ha       |  40 +-
 time/types.ha             |   9 -
 25 files changed, 2220 insertions(+), 1562 deletions(-)
 create mode 100644 datetime/duration.ha
 create mode 100644 datetime/errors.ha
 create mode 100644 datetime/period.ha
 create mode 100644 datetime/reckon.ha
 create mode 100644 datetime/virtual.ha
 create mode 100644 time/chrono/arithmetic.ha

diff --git a/datetime/README b/datetime/README
index ba9ddaf8..a89740ae 100644
--- a/datetime/README
+++ b/datetime/README
@@ -4,28 +4,32 @@ based on the astronomically numbered proleptic Gregorian calendar, as per ISO
of civil date/time and an extension of the [[time::chrono::moment]] type,
optimized for dealing with the Gregorian chronology.

Datetimes are created with [[new]], [[now]], or with one of the various "from_"
functions. Alternatively, use a [[builder]] to construct a datetime
piece-by-piece, by field assignements or by parsing strings with [[parse]].

[[datetime]] instances are designed to be always valid and internally
consistent. They should be treated as immutable, and their fields as private.
All functions herein return valid datetimes (or appropriate errors), and never
modify a datetime's value, even if passed as a pointer, which is used only for
internal caching.

[[datetime]] fields are accessed, evaluated, and cached via the various "field"
functions ([[year]], [[month]], [[day]], etc). Accessing or modifying a
[[datetime]]'s fields directly is discouraged. To mutate a datetime in code, the
use of the [[builder]] interface is recommended.

[[datetime]]s may be localized to different [[time::chrono::timezone]]s via the
[[in]] function. The "field" functions will evaluate the correct values
The [[time::chrono]] module has many useful functions which interoperate with
datetimes. Any [[time::chrono]] function which accepts *monent also accepts
*datetime.

Datetimes are created with [[new]], [[now]], or one of the "from_" functions.
Alternatively, use the [[virtual]] interface to construct a datetime.

The [[virtual]] interface, coupled with the [[realize]] function, provides a way
to handle uncertain or invalid datetime information intermediately, transform
date/time values with arithmetic, [[parse]] date/time strings, and construct new
datetimes safely.

The "observe" functions accept a *datetime and evaluates one of its observed
chonological values. This includes [[year]], [[month]], [[day]], [[hour]] etc.

[[datetime]]s may be localized to different [[time::chrono::locality]]s via the
[[in]] function. The "observe" functions will evaluate the correct values
accordingly. You'll find a standard selection of world timezones in the
[[time::chrono]] module.

To convert datetimes to and from strings, use [[parse]] and [[format]].
See [[parse]] and [[format]] for working with date/time strings.

Timescale-wise datetime arithmetic using the [[time::duration]] type is possible,
with [[add]] and [[time::chrono::diff]].

For arithmetics, use [[diff]], [[add]] and [[hop]]. Note that calendrical
arithmetic is highly irregular with many edge cases, so think carefully about
what you want.
Chronology-wise datetime arithmetic using the [[period]] type is possible, with
[[reckon]] and the [[calculus]] type, [[pdiff]], [[unitdiff]], and [[truncate]].
Note that chronological and calendrical arithmetic is highly irregular due to
overflows and timezone discontinuities, so think carefully about what you want.
diff --git a/datetime/arithmetic.ha b/datetime/arithmetic.ha
index 8ced8d59..d79a6628 100644
--- a/datetime/arithmetic.ha
+++ b/datetime/arithmetic.ha
@@ -7,35 +7,8 @@ use math;
use time;
use time::chrono;

// Represents a span of time in the Gregorian chronology, using nominal units of
// time. Used for datetime arithmetic.
export type period = struct {
	eras: int,
	years: int,

	// Can be 28, 29, 30, or 31 days long
	months: int,

	// Weeks start on Monday
	weeks: int,

	days: int,
	hours: int,
	minutes: int,
	seconds: int,
	nanoseconds: i64,
};

// Specifies the behaviour of calendar arithmetic.
export type calculus = enum int {
	// Units are added in the order of largest (years) to smallest
	// (nanoseconds). If the resulting date does not exist, the first extant
	// date previous to the initial result is returned.
	DEFAULT,
};
// TODO: ^ Expand this

// The nominal units of the Gregorian chronology. Used for datetime arithmetic.
// The nominal units of the Gregorian chronology. Used for chronological
// arithmetic.
export type unit = enum int {
	ERA,
	YEAR,
@@ -48,388 +21,164 @@ export type unit = enum int {
	NANOSECOND,
};

// Returns true if two [[datetime]]s are equivalent.
//
// Equivalence means they represent the same moment in time, regardless of their
// locality or observed chronological values.
export fn eq(a: datetime, b: datetime) bool = {
	return time::compare(*(&a: *time::instant), *(&b: *time::instant)) == 0;
};
// Calculates the [[period]] between two [[datetime]]s, from A to B.
// The returned period, provided to [[reckon]] along with A, will produce B,
// regardless of the [[calculus]] used. All the period's non-zero fields will
// have the same sign.
export fn pdiff(a: datetime, b: datetime) period = {
	let p = period { ... };

// Returns true if [[datetime]] "a" succeeds [[datetime]] "b".
//
// Temporal order is evaluated in a universal frame of reference, regardless of
// their locality or observed chronological values.
export fn after(a: datetime, b: datetime) bool = {
	return time::compare(*(&a: *time::instant), *(&b: *time::instant)) == +1;
};

// Returns true if [[datetime]] "a" precedes [[datetime]] "b".
//
// Temporal order is evaluated in a universal frame of reference, regardless of
// their locality or observed chronological values.
export fn before(a: datetime, b: datetime) bool = {
	return time::compare(*(&a: *time::instant), *(&b: *time::instant)) == -1;
};

// Calculates the [[period]] between two [[datetime]]s.
export fn diff(a: datetime, b: datetime) period = {
	let res = period { ... };
	if (eq(a, b)) {
		return res;
	if (chrono::compare(&a, &b) == 0) {
		return p;
	};
	if (after(b, a)) {
		const tmp = a;

	let reverse = if (chrono::compare(&a, &b) > 0) true else false;
	if (reverse) {
		let tmp = a;
		a = b;
		b = tmp;
	};

	res.years = year(&a) - year(&b);
	p.years = _year(&b) - _year(&a);

	res.months = month(&a) - month(&b);
	if (res.months < 0) {
		res.years -= 1;
		res.months = 12 + res.months;
	p.months = _month(&b) - _month(&a);
	if (p.months < 0) {
		p.years -= 1;
		p.months += 12;
	};

	res.days = day(&a) - day(&b);
	if (res.days < 0) {
		let prev_month_year = year(&a);
		let prev_month = month(&a) - 1;
		if (prev_month == 0) {
			prev_month_year -= 1;
			prev_month = 12;
	p.days = _day(&b) - _day(&a);
	let year = _year(&b);
	let month = _month(&b);
	let daycnt = calc_month_daycnt(year, month);
	for (_day(&a) > daycnt || p.days < 0) {
		month -= 1;
		if (month == 0) {
			year -= 1;
			month = 12;
		};
		const n_days_in_prev_month = calc_n_days_in_month(
			prev_month_year, prev_month);
		res.months -= 1;
		res.days = n_days_in_prev_month + res.days;
		daycnt = calc_month_daycnt(year, month);

		p.months -= 1;
		if (p.months < 0) {
			p.years -= 1;
			p.months += 12;
		};
		p.days += daycnt;
	};

	res.hours = hour(&a) - hour(&b);
	if (res.hours < 0) {
		res.days -= 1;
		res.hours = 24 + res.hours;
	p.hours = _hour(&b) - _hour(&a);
	if (p.hours < 0) {
		p.days -= 1;
		p.hours += 24;
	};

	res.minutes = minute(&a) - minute(&b);
	if (res.minutes < 0) {
		res.hours -= 1;
		res.minutes = 60 + res.minutes;
	p.minutes = _minute(&b) - _minute(&a);
	if (p.minutes < 0) {
		p.hours -= 1;
		p.minutes += 60;
	};

	res.seconds = second(&a) - second(&b);
	if (res.seconds < 0) {
		res.minutes -= 1;
		res.seconds = 60 + res.seconds;
	p.seconds = _second(&b) - _second(&a);
	if (p.seconds < 0) {
		p.minutes -= 1;
		p.seconds += 60;
	};

	res.nanoseconds = nanosecond(&a) - nanosecond(&b);
	if (res.nanoseconds < 0) {
		res.seconds -= 1;
		res.nanoseconds = time::SECOND + res.nanoseconds;
	p.nanoseconds = _nanosecond(&b) - _nanosecond(&a);
	if (p.nanoseconds < 0) {
		p.seconds -= 1;
		p.nanoseconds += 1000000000; // 10E9
	};

	return res;
	return if (reverse) neg(p) else p;
};

// Calculates the difference between two [[datetime]]s using the given nominal
// [[unit]], truncating towards zero.
// Calculates the nominal [[unit]] difference between two [[datetime]]s.
export fn unitdiff(a: datetime, b: datetime, u: unit) i64 = {
	return switch (u) {
	switch (u) {
	case unit::ERA =>
		yield math::absi(era(&a) - era(&b)): i64;
		return era(&b) - era(&a);
	case unit::YEAR =>
		yield diff(a, b).years;
		return pdiff(a, b).years;
	case unit::MONTH =>
		const full_diff = diff(a, b);
		yield full_diff.years * 12 + full_diff.months;
		const d = pdiff(a, b);
		return d.years * 12 + d.months;
	case unit::WEEK =>
		yield unitdiff(a, b, unit::DAY) / 7;
		return unitdiff(a, b, unit::DAY) / 7;
	case unit::DAY =>
		yield math::absi(chrono::getdate(&a) - chrono::getdate(&b)): int;
		return chrono::date(&b) - chrono::date(&a);
	case unit::HOUR =>
		const full_diff = diff(a, b);
		yield (unitdiff(a, b, unit::DAY) * 24) + full_diff.hours;
		return unitdiff(a, b, unit::DAY) * 24 + pdiff(a, b).hours;
	case unit::MINUTE =>
		const full_diff = diff(a, b);
		yield unitdiff(a, b, unit::HOUR) * 60 + full_diff.minutes;
		return unitdiff(a, b, unit::HOUR) * 60 + pdiff(a, b).minutes;
	case unit::SECOND =>
		const full_diff = diff(a, b);
		yield unitdiff(a, b, unit::MINUTE) * 60 + full_diff.seconds;
		return unitdiff(a, b, unit::MINUTE) * 60 + pdiff(a, b).seconds;
	case unit::NANOSECOND =>
		const full_diff = diff(a, b);
		yield unitdiff(a, b, unit::SECOND) * time::SECOND +
			full_diff.nanoseconds;
		return unitdiff(a, b, unit::SECOND) * 1000000000 + pdiff(a, b).nanoseconds;
	};
};

// Returns true if two [[period]]s are numerically equal.
export fn period_eq(a: period, b: period) bool = {
	return a.eras == b.eras &&
		a.years == b.years &&
		a.months == b.months &&
		a.weeks == b.weeks &&
		a.days == b.days &&
		a.hours == b.hours &&
		a.minutes == b.minutes &&
		a.seconds == b.seconds &&
		a.nanoseconds == b.nanoseconds;
};

// Truncates the given [[datetime]] at the provided nominal [[unit]].
//
// For example, truncating to the nearest [[unit::MONTH]] will set the day,
// hour, minute, seconds, and nanoseconds fields to their minimum values.
// For example, truncating to the nearest unit::MONTH will set the 'day',
// 'hour', 'minute', 'second', and 'nanosecond' fields to their minimum values.
export fn truncate(dt: datetime, u: unit) datetime = {
	// TODO: Replace all of the 0s for the zoffset with the actual
	// zoffset once the API is solidified a bit
	// TODO: There exist timezones where midnight is invalid on certain
	// days. The new()! calls will fail, but we probably don't want to '?'
	// propagate [[invalid]] to keep this function's use simple. The minimum
	// values (the zeroes and ones here) can't be hardcoded. They need
	// calculation. We should either handle this here; or probably in
	// realize(), and then use realize() here.
	return switch (u) {
	case unit::ERA =>
		yield new(dt.loc, 0,
		yield new(dt.loc, chrono::mzone(&dt).zoff,
			1, 1, 1,
			0, 0, 0, 0,
		)!;
	case unit::YEAR =>
		yield new(dt.loc, 0,
			year(&dt), 1, 1,
		yield new(dt.loc, chrono::mzone(&dt).zoff,
			_year(&dt), 1, 1,
			0, 0, 0, 0,
		)!;
	case unit::MONTH =>
		yield new(dt.loc, 0,
			year(&dt), month(&dt), 1,
		yield new(dt.loc, chrono::mzone(&dt).zoff,
			_year(&dt), _month(&dt), 1,
			0, 0, 0, 0,
		)!;
	case unit::WEEK =>
		const date = chrono::getdate(&dt) - (weekday(&dt) - 1);
		const date = chrono::date(&dt) - _weekday(&dt);
		const ymd = calc_ymd(date);
		yield new(dt.loc, 0,
		yield new(dt.loc, chrono::mzone(&dt).zoff,
			ymd.0, ymd.1, ymd.2,
			0, 0, 0, 0,
		)!;
	case unit::DAY =>
		yield new(dt.loc, 0,
			year(&dt), month(&dt), day(&dt),
		yield new(dt.loc, chrono::mzone(&dt).zoff,
			_year(&dt), _month(&dt), _day(&dt),
			0, 0, 0, 0,
		)!;
	case unit::HOUR =>
		yield new(dt.loc, 0,
			year(&dt), month(&dt), day(&dt),
			hour(&dt), 0, 0, 0,
		yield new(dt.loc, chrono::mzone(&dt).zoff,
			_year(&dt), _month(&dt), _day(&dt),
			_hour(&dt), 0, 0, 0,
		)!;
	case unit::MINUTE =>
		yield new(dt.loc, 0,
			year(&dt), month(&dt), day(&dt),
			hour(&dt), minute(&dt), 0, 0,
		yield new(dt.loc, chrono::mzone(&dt).zoff,
			_year(&dt), _month(&dt), _day(&dt),
			_hour(&dt), _minute(&dt), 0, 0,
		)!;
	case unit::SECOND =>
		yield new(dt.loc, 0,
			year(&dt), month(&dt), day(&dt),
			hour(&dt), minute(&dt), second(&dt), 0,
		yield new(dt.loc, chrono::mzone(&dt).zoff,
			_year(&dt), _month(&dt), _day(&dt),
			_hour(&dt), _minute(&dt), _second(&dt), 0,
		)!;
	case unit::NANOSECOND =>
		yield dt;
	};
};

// Given a [[datetime]] and a [[period]], "hops" to the minimum value of each
// field (years, months, days, etc) plus or minus an offset, and returns a new
// datetime. This can be used, for example, to find the start of last year.
//
// Consults each period's fields from most to least significant (from years to
// nanoseconds).
//
// If a period's field's value N is zero, it's a no-op. Otherwise, hop will
// reckon to the Nth inter-period point from where last reckoned. This repeats
// until all the given period's fields are exhausted.
//
// 	let dt = ... // 1999-05-13 12:30:45
// 	datetime::hop(dt, datetime::period {
// 		years  = 22, // produces 2021-01-01 00:00:00
// 		months = -1, // produces 2020-11-01 00:00:00
// 		days   = -4, // produces 2020-10-27 00:00:00
// 	});
//
export fn hop(dt: datetime, pp: period...) datetime = {
	let new_dt = dt;
	for (let i = 0z; i < len(pp); i += 1) {
		const p = pp[i];

		if (p.years != 0) {
			const dt_inc = add(new_dt, calculus::DEFAULT,
				period { years = p.years, ... });
			new_dt = truncate(dt_inc, unit::YEAR);
		};
		if (p.months != 0) {
			const dt_inc = add(new_dt, calculus::DEFAULT,
				period { months = p.months, ... });
			new_dt = truncate(dt_inc, unit::MONTH);
		};
		if (p.weeks != 0) {
			const dt_inc = add(new_dt, calculus::DEFAULT,
				period { weeks = p.weeks, ... });
			new_dt = truncate(dt_inc, unit::WEEK);
		};
		if (p.days != 0) {
			const dt_inc = add(new_dt, calculus::DEFAULT,
				period { days = p.days, ... });
			new_dt = truncate(dt_inc, unit::DAY);
		};
		if (p.hours != 0) {
			const dt_inc = add(new_dt, calculus::DEFAULT,
				period { hours = p.hours, ... });
			new_dt = truncate(dt_inc, unit::HOUR);
		};
		if (p.minutes != 0) {
			const dt_inc = add(new_dt, calculus::DEFAULT,
				period { minutes = p.minutes, ... });
			new_dt = truncate(dt_inc, unit::MINUTE);
		};
		if (p.seconds != 0) {
			const dt_inc = add(new_dt, calculus::DEFAULT,
				period { seconds = p.seconds, ... });
			new_dt = truncate(dt_inc, unit::SECOND);
		};
		if (p.nanoseconds != 0) {
			new_dt = add(new_dt, calculus::DEFAULT,
				period { nanoseconds = p.nanoseconds, ... });
		};
	};
	return new_dt;
};

// Adds a period of time to a datetime, most significant units first. Conserves
// relative distance from cyclical points on the calendar when possible. This
// can be used, for example, to find the date one year from now.
//
// 	let dt = ... // 1999-05-13 12:30:45
// 	datetime::add(dt, datetime::calculus::DEFAULT, datetime::period {
// 		years  = 22, // 2021-05-13 12:30:45
// 		months = -1, // 2021-04-13 12:30:45
// 		days   = -4, // 2020-04-09 12:30:45
// 	});
//
export fn add(dt: datetime, flag: calculus, pp: period...) datetime = {
	// TODO: Use [[builder]] to simplify some code.
	let d_year = year(&dt);
	let d_month = month(&dt);
	let d_day = day(&dt);
	let d_hour = hour(&dt);
	let d_minute = minute(&dt);
	let d_second = second(&dt);
	let d_nanosecond = ((nanosecond(&dt)): i64);
	for (let i = 0z; i < len(pp); i += 1) {
		const p = pp[i];

		let latest_date = chrono::getdate(&dt);

		if (p.years != 0) {
			d_year += p.years;
		};
		if (p.months != 0) {
			d_month += p.months;
		};
		if (d_month > 12) {
			d_year += (d_month - 1) / 12;
			d_month = d_month % 12;
		};
		if (d_month < 1) {
			d_year -= (12 + -(d_month - 1)) / 12;
			d_month = 12 - (-d_month % 12);
		};
		const n_days_in_month = calc_n_days_in_month(d_year, d_month);
		if (d_day > n_days_in_month) {
			d_day = n_days_in_month;
		};

		if (p.weeks != 0) {
			p.days += p.weeks * 7;
		};
		latest_date = calc_date_from_ymd(d_year, d_month, d_day)!;
		if (p.days != 0) {
			const new_ymd = calc_ymd(latest_date + p.days);
			d_year = new_ymd.0;
			d_month = new_ymd.1;
			d_day = new_ymd.2;
			latest_date = calc_date_from_ymd(
				d_year, d_month, d_day)!;
		};

		if (p.hours != 0) {
			p.nanoseconds += p.hours * time::HOUR;
		};
		if (p.minutes != 0) {
			p.nanoseconds += p.minutes * time::MINUTE;
		};
		if (p.seconds != 0) {
			p.nanoseconds += p.seconds * time::SECOND;
		};
		if (p.nanoseconds != 0) {
			const ns_in_day = 24 * time::HOUR;
			let overflowed_days = 0;

			if (math::absi(p.nanoseconds): i64 > ns_in_day) {
				overflowed_days +=
					((p.nanoseconds / ns_in_day): int);
				p.nanoseconds %= ns_in_day;
			};

			let new_time = chrono::gettime(&dt) + p.nanoseconds;

			if (new_time >= ns_in_day) {
				overflowed_days += 1;
				new_time -= ns_in_day;
			} else if (new_time < 0) {
				overflowed_days -= 1;
				new_time += ns_in_day;
			};

			if (overflowed_days != 0) {
				const new_date = latest_date + overflowed_days;
				const new_ymd = calc_ymd(new_date);
				d_year = new_ymd.0;
				d_month = new_ymd.1;
				d_day = new_ymd.2;
			};
			const new_hmsn = calc_hmsn(new_time);
			d_hour = new_hmsn.0;
			d_minute = new_hmsn.1;
			d_second = new_hmsn.2;
			d_nanosecond = new_hmsn.3;
		};
	};
	// TODO: Add zoffset back in here once API is settled
	return new(dt.loc, 0,
		d_year, d_month, d_day, d_hour, d_minute, d_second, d_nanosecond: int,
	)!;
};

// Subtracts a calendrical period of time to a datetime, most significant units
// first. Conserves relative distance from cyclical points on the calendar when
// possible.
//
// 	let dt = ... // 1999-05-13 12:30:45
// 	datetime::subtract(dt, datetime::calculus::DEFAULT, datetime::period {
// 		years  = 22, // 1977-05-13 12:30:45
// 		months = -1, // 1977-06-13 12:30:45
// 		days   = -4, // 1977-06-17 12:30:45
// 	});
//
export fn sub(dt: datetime, flag: calculus, pp: period...) datetime = {
	for (let i = 0z; i < len(pp); i += 1) {
		pp[i].eras *= -1;
		pp[i].years *= -1;
		pp[i].months *= -1;
		pp[i].weeks *= -1;
		pp[i].days *= -1;
		pp[i].minutes *= -1;
		pp[i].seconds *= -1;
		pp[i].nanoseconds *= -1;
	};
	return add(dt, flag, pp...);
};

@test fn diff() void = {
@test fn pdiff() void = {
	const cases = [
		(
			new(chrono::UTC, 0, 2021, 1, 15, 0, 0, 0, 0)!,
@@ -540,33 +289,33 @@ export fn sub(dt: datetime, flag: calculus, pp: period...) datetime = {
		const dta = cases[i].0;
		const dtb = cases[i].1;
		const expected = cases[i].2;
		const actual = diff(dta, dtb);
		assert(period_eq(actual, expected), "diff miscalculation");
		const actual = pdiff(dta, dtb);
		assert(peq(actual, expected), "pdiff miscalculation");
	};
};

@test fn unitdiff() void = {
	const cases = [
		(
			new(chrono::UTC, 0, 1994, 8, 27, 11, 20, 1, 2)!,
			new(chrono::UTC, 0, 2022, 1, 5, 13, 53, 30, 20)!,
			new(chrono::UTC, 0,  1994,  8, 27,  11, 20,  1,         2)!,
			new(chrono::UTC, 0,  2022,  1,  5,  13, 53, 30,        20)!,
			(27, 328, 1427, 9993, 239834, 14390073, 863404409i64,
				(863404409i64 * time::SECOND) + 18),
		),
		(
			new(chrono::UTC, 0, 1994, 8, 28, 11, 20, 1, 2)!,
			new(chrono::UTC, 0, 1994, 8, 27, 11, 20, 1, 0)!,
			new(chrono::UTC, 0,  1994,  8, 27,  11, 20,  1,         0)!,
			new(chrono::UTC, 0,  1994,  8, 28,  11, 20,  1,         2)!,
			(0, 0, 0, 1, 24, 1440, 86400i64,
				(86400i64 * time::SECOND) + 2),
		),
		(
			new(chrono::UTC, 0, 1994, 8, 27, 11, 20, 1, 0)!,
			new(chrono::UTC, 0, 1994, 8, 27, 11, 20, 1, 0)!,
			new(chrono::UTC, 0,  1994,  8, 27,  11, 20,  1,         0)!,
			new(chrono::UTC, 0,  1994,  8, 27,  11, 20,  1,         0)!,
			(0, 0, 0, 0, 0, 0, 0i64, 0i64),
		),
		(
			new(chrono::UTC, 0, -500, 1, 1, 0, 59, 1, 0)!,
			new(chrono::UTC, 0, 2000, 1, 1, 23, 1, 1, 0)!,
			new(chrono::UTC, 0,  -500,  1,  1,   0, 59,  1,         0)!,
			new(chrono::UTC, 0,  2000,  1,  1,  23,  1,  1,         0)!,
			(2500, 30000, 130443, 913106, 913106 * 24 + 22,
				(913106 * 24 + 22) * 60 + 2,
				((913106 * 24 + 22) * 60 + 2) * 60i64,
@@ -578,255 +327,70 @@ export fn sub(dt: datetime, flag: calculus, pp: period...) datetime = {
		const dta = cases[i].0;
		const dtb = cases[i].1;
		const expected = cases[i].2;
		assert(unitdiff(dtb, dta, unit::YEAR) == expected.0,
		assert(unitdiff(dta, dtb, unit::YEAR) == expected.0,
			"invalid diff_in_years() result");
		assert(unitdiff(dtb, dta, unit::MONTH) == expected.1,
		assert(unitdiff(dta, dtb, unit::MONTH) == expected.1,
			"invalid diff_in_months() result");
		assert(unitdiff(dtb, dta, unit::WEEK) == expected.2,
		assert(unitdiff(dta, dtb, unit::WEEK) == expected.2,
			"invalid diff_in_weeks() result");
		assert(unitdiff(dtb, dta, unit::DAY) == expected.3,
		assert(unitdiff(dta, dtb, unit::DAY) == expected.3,
			"invalid diff_in_days() result");
		assert(unitdiff(dtb, dta, unit::HOUR) == expected.4,
		assert(unitdiff(dta, dtb, unit::HOUR) == expected.4,
			"invalid diff_in_hours() result");
		assert(unitdiff(dtb, dta, unit::MINUTE) == expected.5,
		assert(unitdiff(dta, dtb, unit::MINUTE) == expected.5,
			"invalid diff_in_minutes() result");
		assert(unitdiff(dtb, dta, unit::SECOND) == expected.6,
		assert(unitdiff(dta, dtb, unit::SECOND) == expected.6,
			"invalid diff_in_seconds() result");
		assert(unitdiff(dtb, dta, unit::NANOSECOND) == expected.7,
		assert(unitdiff(dta, dtb, unit::NANOSECOND) == expected.7,
			"invalid diff_in_nanoseconds() result");
	};
};

@test fn truncate() void = {
	const dt = new(chrono::UTC, 0, 1994, 8, 27, 11, 20, 1, 2)!;
	assert(eq(truncate(dt, unit::ERA),
			new(chrono::UTC, 0, 1, 1, 1, 0, 0, 0, 0)!),
		"invalid truncate() result");
	assert(eq(truncate(dt, unit::YEAR),
			new(chrono::UTC, 0, 1994, 1, 1, 0, 0, 0, 0)!),
		"invalid truncate() result");
	assert(eq(truncate(dt, unit::MONTH),
			new(chrono::UTC, 0, 1994, 8, 1, 0, 0, 0, 0)!),
		"invalid truncate() result");
	assert(eq(truncate(dt, unit::WEEK),
			new(chrono::UTC, 0, 1994, 8, 22, 0, 0, 0, 0)!),
		"invalid truncate() result");
	assert(eq(truncate(dt, unit::DAY),
			new(chrono::UTC, 0, 1994, 8, 27, 0, 0, 0, 0)!),
		"invalid truncate() result");
	assert(eq(truncate(dt, unit::HOUR),
			new(chrono::UTC, 0, 1994, 8, 27, 11, 0, 0, 0)!),
		"invalid truncate() result");
	assert(eq(truncate(dt, unit::MINUTE),
			new(chrono::UTC, 0, 1994, 8, 27, 11, 20, 0, 0)!),
		"invalid truncate() result");
	assert(eq(truncate(dt, unit::SECOND),
			new(chrono::UTC, 0, 1994, 8, 27, 11, 20, 1, 0)!),
		"invalid truncate() result");
	assert(eq(truncate(dt, unit::NANOSECOND), dt),
		"invalid truncate() result");
};

@test fn add() void = {
	const d = new(chrono::UTC, 0, 2022, 2, 4, 3, 14, 7, 0)!;
	const cases = [
		(
			period { years = 1, ... },
			new(chrono::UTC, 0, 2023, 2, 4, 3, 14, 7, 0)!,
		),
		(
			period { years = -23, ... },
			new(chrono::UTC, 0, 1999, 2, 4, 3, 14, 7, 0)!,
		),
		(
			period { months = 2, ... },
			new(chrono::UTC, 0, 2022, 4, 4, 3, 14, 7, 0)!,
		),
		(
			period { months = 11, ... },
			new(chrono::UTC, 0, 2023, 1, 4, 3, 14, 7, 0)!,
		),
		(
			period { months = -1, ... },
			new(chrono::UTC, 0, 2022, 1, 4, 3, 14, 7, 0)!,
		),
		(
			period { months = -2, ... },
			new(chrono::UTC, 0, 2021, 12, 4, 3, 14, 7, 0)!,
		),
		(
			period { days = 3, ... },
			new(chrono::UTC, 0, 2022, 2, 7, 3, 14, 7, 0)!,
		),
		(
			period { days = 33, ... },
			new(chrono::UTC, 0, 2022, 3, 9, 3, 14, 7, 0)!,
		),
		(
			period { days = 333, ... },
			new(chrono::UTC, 0, 2023, 1, 3, 3, 14, 7, 0)!,
		),
		(
			period { days = -2, ... },
			new(chrono::UTC, 0, 2022, 2, 2, 3, 14, 7, 0)!,
		),
		(
			period { days = -4, ... },
			new(chrono::UTC, 0, 2022, 1, 31, 3, 14, 7, 0)!,
		),
		(
			period { days = -1337, ... },
			new(chrono::UTC, 0, 2018, 6, 8, 3, 14, 7, 0)!,
		),
		(
			period { hours = 1, ... },
			new(chrono::UTC, 0, 2022, 2, 4, 4, 14, 7, 0)!,
		),
		(
			period { hours = 24, ... },
			new(chrono::UTC, 0, 2022, 2, 5, 3, 14, 7, 0)!,
		),
		(
			period { hours = 25, ... },
			new(chrono::UTC, 0, 2022, 2, 5, 4, 14, 7, 0)!,
		),
		(
			period { hours = 123456, ... },
			new(chrono::UTC, 0, 2036, 3, 6, 3, 14, 7, 0)!,
		),
		(
			period { hours = -2, ... },
			new(chrono::UTC, 0, 2022, 2, 4, 1, 14, 7, 0)!,
		),
		(
			period { hours = -24, ... },
			new(chrono::UTC, 0, 2022, 2, 3, 3, 14, 7, 0)!,
		),
		(
			period { hours = -123456, ... },
			new(chrono::UTC, 0, 2008, 1, 5, 3, 14, 7, 0)!,
		),
		(
			period { seconds = 2, ... },
			new(chrono::UTC, 0, 2022, 2, 4, 3, 14, 9, 0)!,
		),
		(
			period { seconds = 666666666, ... },
			new(chrono::UTC, 0, 2043, 3, 22, 4, 25, 13, 0)!,
		),
		(
			period { seconds = -2, ... },
			new(chrono::UTC, 0, 2022, 2, 4, 3, 14, 5, 0)!,
		),
		(
			period { seconds = -666666666, ... },
			new(chrono::UTC, 0, 2000, 12, 20, 2, 3, 1, 0)!,
		),
		(
			period { nanoseconds = 123, ... },
			new(chrono::UTC, 0, 2022, 2, 4, 3, 14, 7, 123)!,
		),
		(
			period { nanoseconds = 1361661361461, ... },
			new(chrono::UTC, 0, 2022, 2, 4, 3, 36, 48, 661361461)!,
		),
		(
			period { nanoseconds = -1361661361461, ... },
			new(chrono::UTC, 0, 2022, 2, 4, 2, 51, 25, 338638539)!,
		),
		(
			period { months = 1, seconds = -666666666, ... },
			new(chrono::UTC, 0, 2001, 1, 17, 2, 3, 1, 0)!,
		),
		(
			period { months = 1, seconds = -666666666, ... },
			new(chrono::UTC, 0, 2001, 1, 17, 2, 3, 1, 0)!,
		),
		(
			period {
				years = -1,
				months = -2,
				weeks = -3,
				days = -4,
				hours = -5,
				minutes = -6,
				seconds = -7,
				nanoseconds = -8,
				...
			},
			new(chrono::UTC, 0, 2020, 11, 8, 22, 7, 59, 999999992)!,
		),
		(
			period {
				years = 1,
				months = 2,
				weeks = 3,
				days = 4,
				hours = 5,
				minutes = 6,
				seconds = 7,
				nanoseconds = 8,
				...
			},
			new(chrono::UTC, 0, 2023, 4, 29, 8, 20, 14, 8)!,
		),
		(
			period {
				years = 1,
				months = -2,
				weeks = 3,
				days = -5,
				hours = 8,
				minutes = -13,
				seconds = 21,
				nanoseconds = -34,
				...
			},
			new(chrono::UTC, 0, 2022, 12, 20, 11, 1, 27, 999999966)!,
		),
		(
			period {
				years = -1,
				months = 12,
				weeks = -52,
				days = -31,
				hours = 24,
				minutes = -3600,
				seconds = 3600,
				nanoseconds = -86400000000000,
				...
			},
			new(chrono::UTC, 0, 2021, 1, 2, 16, 14, 7, 0)!,
		),
	];
	for (let i = 0z; i < len(cases); i += 1) {
		const p = cases[i].0;
		const expected = cases[i].1;
		const actual = add(d, calculus::DEFAULT, p);
		assert(eq(actual, expected), "addition miscalculation");
	};
};

@test fn sub() void = {
	const d = new(chrono::UTC, 0, 2022, 2, 4, 3, 14, 7, 0)!;
	const cases = [
		(
			period { years = 1, ... },
			new(chrono::UTC, 0, 2021, 2, 4, 3, 14, 7, 0)!,
		),
		(
			period { months = 2, ... },
			new(chrono::UTC, 0, 2021, 12, 4, 3, 14, 7, 0)!,
		),
		(
			period { months = 14, ... },
			new(chrono::UTC, 0, 2020, 12, 4, 3, 14, 7, 0)!,
		),
	];
	for (let i = 0z; i < len(cases); i += 1) {
		const p = cases[i].0;
		const expected = cases[i].1;
		const actual = sub(d, calculus::DEFAULT, p);
		assert(eq(actual, expected), "subtraction miscalculation");
	};
	assert(chrono::eq(
			&truncate(dt, unit::ERA),
			&new(chrono::UTC, 0, 1, 1, 1, 0, 0, 0, 0)!)!,
		"invalid truncate() result 01");

	assert(chrono::eq(
			&truncate(dt, unit::YEAR),
			&new(chrono::UTC, 0, 1994, 1, 1, 0, 0, 0, 0)!)!,
		"invalid truncate() result 02");

	assert(chrono::eq(
			&truncate(dt, unit::MONTH),
			&new(chrono::UTC, 0, 1994, 8, 1, 0, 0, 0, 0)!)!,
		"invalid truncate() result 03");

	assert(chrono::eq(
			&truncate(dt, unit::WEEK),
			&new(chrono::UTC, 0, 1994, 8, 22, 0, 0, 0, 0)!)!,
		"invalid truncate() result 04");

	assert(chrono::eq(
			&truncate(dt, unit::DAY),
			&new(chrono::UTC, 0, 1994, 8, 27, 0, 0, 0, 0)!)!,
		"invalid truncate() result 05");

	assert(chrono::eq(
			&truncate(dt, unit::HOUR),
			&new(chrono::UTC, 0, 1994, 8, 27, 11, 0, 0, 0)!)!,
		"invalid truncate() result 06");

	assert(chrono::eq(
			&truncate(dt, unit::MINUTE),
			&new(chrono::UTC, 0, 1994, 8, 27, 11, 20, 0, 0)!)!,
		"invalid truncate() result 07");

	assert(chrono::eq(
			&truncate(dt, unit::SECOND),
			&new(chrono::UTC, 0, 1994, 8, 27, 11, 20, 1, 0)!)!,
		"invalid truncate() result 08");

	assert(chrono::eq(
			&truncate(dt, unit::NANOSECOND),
			&dt)!,
		"invalid truncate() result 09");
};
diff --git a/datetime/chronology.ha b/datetime/chronology.ha
index 230ef414..b0fc9a35 100644
--- a/datetime/chronology.ha
+++ b/datetime/chronology.ha
@@ -7,15 +7,6 @@ use time::chrono;
// These functions are renamed to avoid namespace conflicts, like in the
// parameters of the [[new]] function.

// TODO: For [[epochal]]: Use Hare epoch or Gregorian epoch? Make two function?
// TODO: Create an exported [[zeroweekday]] field function.

// Returns a [[datetime]]'s number of days since the calendar epoch 0000-01-01.
export fn epochal(dt: *datetime) chrono::date = _epochal(dt);

// Returns a [[datetime]]'s number of seconds since the Unix epoch 1970-01-01.
export fn epochunix(dt: *datetime) int = _epochunix(dt);

// Returns a [[datetime]]'s era.
export fn era(dt: *datetime) int = _era(dt);

@@ -28,7 +19,7 @@ export fn month(dt: *datetime) int = _month(dt);
// Returns a [[datetime]]'s day of the month.
export fn day(dt: *datetime) int = _day(dt);

// Returns a [[datetime]]'s day of the week.
// Returns a [[datetime]]'s day of the week; Monday=0 to Sunday=6.
export fn weekday(dt: *datetime) int = _weekday(dt);

// Returns a [[datetime]]'s ordinal day of the year.
@@ -58,14 +49,6 @@ export fn second(dt: *datetime) int = _second(dt);
// Returns a [[datetime]]'s nanosecond of the second.
export fn nanosecond(dt: *datetime) int = _nanosecond(dt);

fn _epochal(dt: *datetime) chrono::date = {
	return chrono::getdate(dt) - EPOCHAL_GREGORIAN;
};

fn _epochunix(dt: *datetime) int = {
	return time::unix(*(dt: *time::instant)): int;
};

fn _era(dt: *datetime) int = {
	match (dt.era) {
	case void =>
@@ -82,7 +65,7 @@ fn _era(dt: *datetime) int = {
fn _year(dt: *datetime) int = {
	match (dt.year) {
	case void =>
		const ymd = calc_ymd(chrono::getdate(dt));
		const ymd = calc_ymd(chrono::date(dt));
		dt.year = ymd.0;
		dt.month = ymd.1;
		dt.day = ymd.2;
@@ -95,7 +78,7 @@ fn _year(dt: *datetime) int = {
fn _month(dt: *datetime) int = {
	match (dt.month) {
	case void =>
		const ymd = calc_ymd(chrono::getdate(dt));
		const ymd = calc_ymd(chrono::date(dt));
		dt.year = ymd.0;
		dt.month = ymd.1;
		dt.day = ymd.2;
@@ -108,7 +91,7 @@ fn _month(dt: *datetime) int = {
fn _day(dt: *datetime) int = {
	match (dt.day) {
	case void =>
		const ymd = calc_ymd(chrono::getdate(dt));
		const ymd = calc_ymd(chrono::date(dt));
		dt.year = ymd.0;
		dt.month = ymd.1;
		dt.day = ymd.2;
@@ -121,7 +104,7 @@ fn _day(dt: *datetime) int = {
fn _weekday(dt: *datetime) int = {
	match (dt.weekday) {
	case void =>
		dt.weekday = calc_weekday(chrono::getdate(dt));
		dt.weekday = calc_weekday(chrono::date(dt));
		return dt.weekday: int;
	case let y: int =>
		return y;
@@ -238,7 +221,7 @@ fn _isoweek(dt: *datetime) int = {
fn _hour(dt: *datetime) int = {
	match (dt.hour) {
	case void =>
		const hmsn = calc_hmsn(chrono::gettime(dt));
		const hmsn = calc_hmsn(chrono::time(dt));
		dt.hour = hmsn.0;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
@@ -252,7 +235,7 @@ fn _hour(dt: *datetime) int = {
fn _minute(dt: *datetime) int = {
	match (dt.minute) {
	case void =>
		const hmsn = calc_hmsn(chrono::gettime(dt));
		const hmsn = calc_hmsn(chrono::time(dt));
		dt.hour = hmsn.0;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
@@ -266,7 +249,7 @@ fn _minute(dt: *datetime) int = {
fn _second(dt: *datetime) int = {
	match (dt.second) {
	case void =>
		const hmsn = calc_hmsn(chrono::gettime(dt));
		const hmsn = calc_hmsn(chrono::time(dt));
		dt.hour = hmsn.0;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
@@ -280,7 +263,7 @@ fn _second(dt: *datetime) int = {
fn _nanosecond(dt: *datetime) int = {
	match (dt.nanosecond) {
	case void =>
		const hmsn = calc_hmsn(chrono::gettime(dt));
		const hmsn = calc_hmsn(chrono::time(dt));
		dt.hour = hmsn.0;
		dt.minute = hmsn.1;
		dt.second = hmsn.2;
diff --git a/datetime/date.ha b/datetime/date.ha
index 00ddbb18..3b7b494d 100644
--- a/datetime/date.ha
+++ b/datetime/date.ha
@@ -15,7 +15,7 @@ export def EPOCHAL_JULIAN: i64 = -2440588;
export def EPOCHAL_GREGORIAN: i64 = -719164;

// Calculates whether a year is a leap year.
export fn is_leap_year(y: int) bool = {
export fn isleapyear(y: int) bool = {
	return if (y % 4 != 0) false
	else if (y % 100 != 0) true
	else if (y % 400 != 0) false
@@ -25,37 +25,29 @@ export fn is_leap_year(y: int) bool = {
// Calculates whether a given year, month, and day-of-month, is a valid date.
fn is_valid_ymd(y: int, m: int, d: int) bool = {
	return m >= 1 && m <= 12 && d >= 1 &&
		d <= calc_n_days_in_month(y, m);
		d <= calc_month_daycnt(y, m);
};

// Calculates whether a given year, and day-of-year, is a valid date.
fn is_valid_yd(y: int, yd: int) bool = {
	return yd >= 1 && yd <= calc_n_days_in_year(y);
	return yd >= 1 && yd <= calc_year_daycnt(y);
};

// Calculates the number of days in the given month of the given year.
fn calc_n_days_in_month(y: int, m: int) int = {
fn calc_month_daycnt(y: int, m: int) int = {
	const days_per_month: [_]int = [
		31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
	];
	if (m == 2) {
		if (is_leap_year(y)) {
			return 29;
		} else {
			return 28;
		};
		return if (isleapyear(y)) 29 else 28;
	} else {
		return days_per_month[m - 1];
	};
};

// Calculates the number of days in a given year.
fn calc_n_days_in_year(y: int) int = {
	if (is_leap_year(y)) {
		return 366;
	} else {
		return 365;
	};
fn calc_year_daycnt(y: int) int = {
	return if (isleapyear(y)) 366 else 365;
};

// Calculates the day-of-week of January 1st, given a year.
@@ -67,7 +59,7 @@ fn calc_janfirstweekday(y: int) int = {
		+ 4 * ((y - 1) % 100)
		+ 6 * ((y - 1) % 400)
	) % 7;
	return wd + 1;
	return wd;
};

// Calculates the era, given a year.
@@ -80,7 +72,7 @@ fn calc_era(y: int) int = {
};

// Calculates the year, month, and day-of-month, given an epochal day.
fn calc_ymd(e: chrono::date) (int, int, int) = {
fn calc_ymd(e: i64) (int, int, int) = {
	// Algorithm adapted from:
	// https://en.wikipedia.org/wiki/Julian_day#Julian_or_Gregorian_calendar_from_Julian_day_number
	//
@@ -122,7 +114,7 @@ fn calc_yearday(y: int, m: int, d: int) int = {
		273, 304, 334,
	];

	if (m >= 3 && is_leap_year(y)) {
	if (m >= 3 && isleapyear(y)) {
		return months_firsts[m - 1] + d + 1;
	} else {
		return months_firsts[m - 1] + d;
@@ -136,9 +128,9 @@ fn calc_isoweekyear(y: int, m: int, d: int, wd: int) int = {
		// if the date is within a week whose Thurday
		// belongs to the previous gregorian year
		m == 1 && (
			(d == 1 && (wd == 5 || wd == 6 || wd == 7))
			|| (d == 2 && (wd == 6 || wd == 7))
			|| (d == 3 && wd == 7)
			(d == 1 && (wd == 4 || wd == 5 || wd == 6))
			|| (d == 2 && (wd == 5 || wd == 6))
			|| (d == 3 && wd == 6)
		)
	) {
		return y - 1;
@@ -146,9 +138,9 @@ fn calc_isoweekyear(y: int, m: int, d: int, wd: int) int = {
		// if the date is within a week whose Thurday
		// belongs to the next gregorian year
		m == 12 && (
			(d == 29 && wd == 1)
			|| (d == 30 && (wd == 1 || wd == 2))
			|| (d == 31 && (wd == 1 || wd == 2 || wd == 3))
			(d == 29 && wd == 0)
			|| (d == 30 && (wd == 0 || wd == 1))
			|| (d == 31 && (wd == 0 || wd == 1 || wd == 2))
		)
	) {
		return y + 1;
@@ -160,64 +152,48 @@ fn calc_isoweekyear(y: int, m: int, d: int, wd: int) int = {
// Calculates the ISO week,
// given a year, week, day-of-week, and day-of-year.
fn calc_isoweek(y: int, w: int) int = {
	const jan1wd = calc_janfirstweekday(y);
	const iw = if (jan1wd == 1) {
		yield w;
	} else if (jan1wd == 2 || jan1wd == 3 || jan1wd == 4) {
		yield w + 1;
	} else {
		yield if (w == 0) {
			yield if (jan1wd == 5) {
				yield 53;
			} else if (jan1wd == 6) {
				yield if (is_leap_year(y - 1)) {
					yield 53;
				} else {
					yield 52;
				};
			} else if (jan1wd == 7) {
				yield 52;
			} else {
				// all jan1wd values exhausted
				abort("Unreachable");
			};
		} else {
			yield w;
	switch (calc_janfirstweekday(y)) {
	case 0 =>
		return w;
	case 1, 2, 3 =>
		return w + 1;
	case 4 =>
		return if (w != 0) w else 53;
	case 5 =>
		return if (w != 0) w else {
			yield if (isleapyear(y - 1)) 53 else 52;
		};
	case 6 =>
		return if (w != 0) w else 52;
	case =>
		abort("Unreachable");
	};
	return iw;
};

// Calculates the week within a Gregorian year [0..53],
// given a day-of-year and day-of-week.
// All days in a year before the year's first Monday belong to week 0.
fn calc_week(yd: int, wd: int) int = {
	return (yd + 7 - wd) / 7;
	return (yd + 6 - wd) / 7;
};

// Calculates the week within a Gregorian year [0..53],
// given a day-of-year and day-of-week.
// All days in a year before the year's first Sunday belong to week 0.
fn calc_sundayweek(yd: int, wd: int) int = {
	return (yd + 6 - (wd % 7)) / 7;
	return (yd + 6 - ((wd + 1) % 7)) / 7;
};

// Calculates the day-of-week, given a epochal day,
// from Monday=1 to Sunday=7.
fn calc_weekday(e: chrono::date) int = {
	const wd = ((e + 3) % 7 + 1): int;
	return if (wd > 0) wd else wd + 7;
};

// Calculates the zero-indexed day-of-week, given a day-of-week,
// from Monday=0 to Sunday=6.
fn calc_zeroweekday(wd: int) int = {
	return wd - 1;
fn calc_weekday(e: i64) int = {
	const wd = ((e + 3) % 7): int;
	return (wd + 7) % 7;
};

// Calculates the [[chrono::date]],
// Calculates the date,
// given a year, month, and day-of-month.
fn calc_date_from_ymd(y: int, m: int, d: int) (chrono::date | invalid) = {
fn calc_date__ymd(y: int, m: int, d: int) (i64 | invalid) = {
	if (!is_valid_ymd(y, m, d)) {
		return invalid;
	};
@@ -236,24 +212,24 @@ fn calc_date_from_ymd(y: int, m: int, d: int) (chrono::date | invalid) = {
	return e;
};

// Calculates the [[chrono::date]],
// Calculates the date,
// given a year, week, and day-of-week.
fn calc_date_from_ywd(y: int, w: int, wd: int) (chrono::date | invalid) = {
fn calc_date__ywd(y: int, w: int, wd: int) (i64 | invalid) = {
	const jan1wd = calc_janfirstweekday(y);
	const yd = wd - jan1wd + 1 + 7 * w;
	return calc_date_from_yd(y, yd)?;
	const yd = wd - jan1wd + 7 * w;
	return calc_date__yd(y, yd)?;
};

// Calculates the [[chrono::date]],
// Calculates the date,
// given a year and day-of-year.
fn calc_date_from_yd(y: int, yd: int) (chrono::date | invalid) = {
	if (yd < 1 || yd > calc_n_days_in_year(y)) {
fn calc_date__yd(y: int, yd: int) (i64 | invalid) = {
	if (yd < 1 || yd > calc_year_daycnt(y)) {
		return invalid;
	};
	return calc_date_from_ymd(y, 1, 1)? + yd - 1;
	return calc_date__ymd(y, 1, 1)? + yd - 1;
};

@test fn calc_date_from_ymd() void = {
@test fn calc_date__ymd() void = {
	const cases = [
		(( -768,  2,  5),  -999999, false),
		((   -1, 12, 31),  -719529, false),
@@ -289,20 +265,20 @@ fn calc_date_from_yd(y: int, yd: int) (chrono::date | invalid) = {
		const params = cases[i].0;
		const expect = cases[i].1;
		const should_error = cases[i].2;
		const actual = calc_date_from_ymd(
		const actual = calc_date__ymd(
			params.0, params.1, params.2,
		);

		if (should_error) {
			assert(actual is invalid, "invalid date accepted");
		} else {
			assert(actual is chrono::date, "valid date not accepted");
			assert(actual as chrono::date == expect, "date miscalculation");
			assert(actual is i64, "valid date not accepted");
			assert(actual as i64 == expect, "date miscalculation");
		};
	};
};

@test fn calc_date_from_ywd() void = {
@test fn calc_date__ywd() void = {
	const cases = [
		(( -768,  0, 4), -1000034),
		(( -768,  5, 4), -999999),
@@ -340,13 +316,13 @@ fn calc_date_from_yd(y: int, yd: int) (chrono::date | invalid) = {
	for (let i = 0z; i < len(cases); i += 1) {
		const ywd = cases[i].0;
		const expected = cases[i].1;
		const actual = calc_date_from_ywd(ywd.0, ywd.1, ywd.2)!;
		const actual = calc_date__ywd(ywd.0, ywd.1, ywd.2)!;
		assert(actual == expected,
			"incorrect calc_date_from_ywd() result");
			"incorrect calc_date__ywd() result");
	};
};

@test fn calc_date_from_yd() void = {
@test fn calc_date__yd() void = {
	const cases = [
		( -768, 36,  -999999),
		(   -1, 365, -719529),
@@ -375,14 +351,14 @@ fn calc_date_from_yd(y: int, yd: int) (chrono::date | invalid) = {
		const y = cases[i].0;
		const yd = cases[i].1;
		const expected = cases[i].2;
		const actual = calc_date_from_yd(y, yd)!;
		const actual = calc_date__yd(y, yd)!;
		assert(expected == actual,
			"error in date calculation from yd");
	};
	assert(calc_date_from_yd(2020, 0) is invalid,
		"calc_date_from_yd() did not reject invalid yearday");
	assert(calc_date_from_yd(2020, 400) is invalid,
		"calc_date_from_yd() did not reject invalid yearday");
	assert(calc_date__yd(2020, 0) is invalid,
		"calc_date__yd() did not reject invalid yearday");
	assert(calc_date__yd(2020, 400) is invalid,
		"calc_date__yd() did not reject invalid yearday");
};

@test fn calc_ymd() void = {
@@ -454,21 +430,21 @@ fn calc_date_from_yd(y: int, yd: int) (chrono::date | invalid) = {

@test fn calc_week() void = {
	const cases = [
		((1, 1), 1),
		((1, 2), 0),
		((1, 3), 0),
		((1, 4), 0),
		((1, 5), 0),
		((1, 6), 0),
		((1, 7), 0),
		((21, 2), 3),
		((61, 3), 9),
		((193, 5), 27),
		((229, 1), 33),
		((286, 4), 41),
		((341, 7), 48),
		((365, 6), 52),
		((366, 1), 53),
		((  1, 0),  1),
		((  1, 1),  0),
		((  1, 2),  0),
		((  1, 3),  0),
		((  1, 4),  0),
		((  1, 5),  0),
		((  1, 6),  0),
		(( 21, 1),  3),
		(( 61, 2),  9),
		((193, 4), 27),
		((229, 0), 33),
		((286, 3), 41),
		((341, 6), 48),
		((365, 5), 52),
		((366, 0), 53),
	];

	for (let i = 0z; i < len(cases); i += 1) {
@@ -481,21 +457,21 @@ fn calc_date_from_yd(y: int, yd: int) (chrono::date | invalid) = {

@test fn calc_sundayweek() void = {
	const cases = [
		((1, 1), 0),
		((1, 2), 0),
		((1, 3), 0),
		((1, 4), 0),
		((1, 5), 0),
		((1, 6), 0),
		((1, 7), 1),
		((21, 2), 3),
		((61, 3), 9),
		((193, 5), 27),
		((229, 1), 33),
		((286, 4), 41),
		((341, 7), 49),
		((365, 6), 52),
		((366, 1), 53),
		((  1, 0),  0),
		((  1, 1),  0),
		((  1, 2),  0),
		((  1, 3),  0),
		((  1, 4),  0),
		((  1, 5),  0),
		((  1, 6),  1),
		(( 21, 1),  3),
		(( 61, 2),  9),
		((193, 4), 27),
		((229, 0), 33),
		((286, 3), 41),
		((341, 6), 49),
		((365, 5), 52),
		((366, 0), 53),
	];

	for (let i = 0z; i < len(cases); i += 1) {
@@ -508,27 +484,27 @@ fn calc_date_from_yd(y: int, yd: int) (chrono::date | invalid) = {

@test fn calc_weekday() void = {
	const cases = [
		(-999999, 4), // -0768-02-05
		(-719529, 5), // -0001-12-31
		(-719528, 6), //  0000-01-01
		(-719527, 7), //  0000-01-02
		(-719163, 7), //  0000-12-31
		(-719162, 1), //  0001-01-01
		(-719161, 2), //  0001-01-02
		(  -1745, 2), //  1965-03-23
		(     -1, 3), //  1969-12-31
		(      0, 4), //  1970-01-01
		(      1, 5), //  1970-01-02
		(  10956, 5), //  1999-12-31
		(  10957, 6), //  2000-01-01
		(  10958, 7), //  2000-01-02
		(  24854, 1), //  2038-01-18
		(  24855, 2), //  2038-01-19
		(  24856, 3), //  2038-01-20
		( 100000, 2), //  2243-10-17
		( 999999, 4), //  4707-11-28
		(1000000, 5), //  4707-11-29
		(9999999, 6), // 29349-01-25
		(-999999, 3), // -0768-02-05
		(-719529, 4), // -0001-12-31
		(-719528, 5), //  0000-01-01
		(-719527, 6), //  0000-01-02
		(-719163, 6), //  0000-12-31
		(-719162, 0), //  0001-01-01
		(-719161, 1), //  0001-01-02
		(  -1745, 1), //  1965-03-23
		(     -1, 2), //  1969-12-31
		(      0, 3), //  1970-01-01
		(      1, 4), //  1970-01-02
		(  10956, 4), //  1999-12-31
		(  10957, 5), //  2000-01-01
		(  10958, 6), //  2000-01-02
		(  24854, 0), //  2038-01-18
		(  24855, 1), //  2038-01-19
		(  24856, 2), //  2038-01-20
		( 100000, 1), //  2243-10-17
		( 999999, 3), //  4707-11-28
		(1000000, 4), //  4707-11-29
		(9999999, 5), // 29349-01-25
	];
	for (let i = 0z; i < len(cases); i += 1) {
		const paramt = cases[i].0;
@@ -541,77 +517,77 @@ fn calc_date_from_yd(y: int, yd: int) (chrono::date | invalid) = {
@test fn calc_janfirstweekday() void = {
	const cases = [
	//	 year   weekday
		(1969,  3),
		(1970,  4),
		(1971,  5),
		(1972,  6),
		(1973,  1),
		(1974,  2),
		(1975,  3),
		(1976,  4),
		(1977,  6),
		(1978,  7),
		(1979,  1),
		(1980,  2),
		(1981,  4),
		(1982,  5),
		(1983,  6),
		(1984,  7),
		(1985,  2),
		(1986,  3),
		(1987,  4),
		(1988,  5),
		(1989,  7),
		(1990,  1),
		(1991,  2),
		(1992,  3),
		(1993,  5),
		(1994,  6),
		(1995,  7),
		(1996,  1),
		(1997,  3),
		(1998,  4),
		(1999,  5),
		(2000,  6),
		(2001,  1),
		(2002,  2),
		(2003,  3),
		(2004,  4),
		(2005,  6),
		(2006,  7),
		(2007,  1),
		(2008,  2),
		(2009,  4),
		(2010,  5),
		(2011,  6),
		(2012,  7),
		(2013,  2),
		(2014,  3),
		(2015,  4),
		(2016,  5),
		(2017,  7),
		(2018,  1),
		(2019,  2),
		(2020,  3),
		(2021,  5),
		(2022,  6),
		(2023,  7),
		(2024,  1),
		(2025,  3),
		(2026,  4),
		(2027,  5),
		(2028,  6),
		(2029,  1),
		(2030,  2),
		(2031,  3),
		(2032,  4),
		(2033,  6),
		(2034,  7),
		(2035,  1),
		(2036,  2),
		(2037,  4),
		(2038,  5),
		(2039,  6),
		(1969,  2),
		(1970,  3),
		(1971,  4),
		(1972,  5),
		(1973,  0),
		(1974,  1),
		(1975,  2),
		(1976,  3),
		(1977,  5),
		(1978,  6),
		(1979,  0),
		(1980,  1),
		(1981,  3),
		(1982,  4),
		(1983,  5),
		(1984,  6),
		(1985,  1),
		(1986,  2),
		(1987,  3),
		(1988,  4),
		(1989,  6),
		(1990,  0),
		(1991,  1),
		(1992,  2),
		(1993,  4),
		(1994,  5),
		(1995,  6),
		(1996,  0),
		(1997,  2),
		(1998,  3),
		(1999,  4),
		(2000,  5),
		(2001,  0),
		(2002,  1),
		(2003,  2),
		(2004,  3),
		(2005,  5),
		(2006,  6),
		(2007,  0),
		(2008,  1),
		(2009,  3),
		(2010,  4),
		(2011,  5),
		(2012,  6),
		(2013,  1),
		(2014,  2),
		(2015,  3),
		(2016,  4),
		(2017,  6),
		(2018,  0),
		(2019,  1),
		(2020,  2),
		(2021,  4),
		(2022,  5),
		(2023,  6),
		(2024,  0),
		(2025,  2),
		(2026,  3),
		(2027,  4),
		(2028,  5),
		(2029,  0),
		(2030,  1),
		(2031,  2),
		(2032,  3),
		(2033,  5),
		(2034,  6),
		(2035,  0),
		(2036,  1),
		(2037,  3),
		(2038,  4),
		(2039,  5),
	];
	for (let i = 0z; i < len(cases); i += 1) {
		const paramt = cases[i].0;
diff --git a/datetime/datetime.ha b/datetime/datetime.ha
index dddc5981..03988ae5 100644
--- a/datetime/datetime.ha
+++ b/datetime/datetime.ha
@@ -8,6 +8,24 @@ use time::chrono;
// Invalid [[datetime]].
export type invalid = !chrono::invalid;

// A date/time object; a [[time::chrono::moment]] wrapper optimized for the
// Gregorian chronology
//
// It is by extension a [[time::instant]] wrapper, and carries information about
// its [[time::chrono::timescale]] and [[time::chrono::locality]].
//
// This object should be treated as private and immutable. Directly mutating its
// fields causes undefined behaviour when used with module functions. Likewise,
// interrogating the fields' type and value (e.g. using match statements) is
// also improper.
//
// A datetime observes various chronological values, cached in its fields. To
// evaluate and obtain these values, use the various "observe" functions
// ([[year]], [[day]], etc.). These values are derived from the embedded moment
// information, and thus are guaranteed to be valid.
//
// See [[virtual]] for an public, mutable, intermediary representation of a
// datetime, which waives guarantees of validity.
export type datetime = struct {
	chrono::moment,

@@ -29,12 +47,12 @@ export type datetime = struct {
};

fn init() datetime = datetime {
	loc         = chrono::LOCAL,
	sec         = 0,
	nsec        = 0,
	loc         = chrono::UTC,
	zone        = null,
	date        = void,
	time        = void,
	zone        = void,

	era         = void,
	year        = void,
@@ -53,28 +71,51 @@ fn init() datetime = datetime {
	nanosecond  = void,
};

// Creates a new datetime. When loc=void, defaults to chrono::local.
// Evaluates and populates all of a [[datetime]]'s fields.
fn all(dt: *datetime) *datetime = {
	_era(dt);
	_year(dt);
	_month(dt);
	_day(dt);
	_yearday(dt);
	_isoweekyear(dt);
	_isoweek(dt);
	_week(dt);
	_sundayweek(dt);
	_weekday(dt);

	_hour(dt);
	_minute(dt);
	_second(dt);
	_nanosecond(dt);

	return dt;
};

// Creates a new datetime. A maximum of 7 optional field arguments can be given:
// year, month, day-of-month, hour, minute, second, nanosecond. 8 or more causes
// an abort.
//
// 	// 0000 Jan  1st 00:00:00.000000000 +0000 UTC
// 	// 0000-01-01 00:00:00.000000000 +0000 UTC UTC
// 	datetime::new(time::chrono::UTC, 0);
//
// 	// 2038 Jan 19th 03:14:07.000000618 +0000 UTC
// 	datetime::new(time::chrono::UTC, 0, 2038, 1, 19, 3, 14, 7, 618);
// 	// 2019-12-27 20:07:08.000031415 +0000 UTC UTC
// 	datetime::new(time::chrono::UTC, 0,  2019, 12, 27,  20, 07, 08, 31415);
//
// 	// 2038 Jan 19th 02:00:00.000000000 +0100 Europe/Amsterdam
// 	datetime::new(&time::chrono::tz("Europe/Amsterdam"), 1 * time::HOUR,
// 		2038, 1, 19, 2);
// 	// 2019-12-27 21:00:00.000000000 +0100 CET Europe/Amsterdam
// 	datetime::new(time::chrono::tz("Europe/Amsterdam")!, 1 * time::HOUR,
// 		2019, 12, 27,  21);
//
// 'offs' is the zone offset from the normal timezone (in most cases, UTC). For
// example, the "Asia/Tokyo" timezone has a single zoffset of +9 hours, but the
// "Australia/Sydney" timezone has zoffsets +10 hours and +11 hours, as they
// 'zo' is the zone offset from the normal timezone (in most cases, UTC). For
// example, the "Asia/Tokyo" timezone has a single zoff of +9 hours, but the
// "Australia/Sydney" timezone has zoffs +10 hours and +11 hours, as they
// observe Daylight Saving Time.
//
// If specified (non-void), 'offs' must match one of the timezone's observed
// zoffsets, or will fail. See [[time::chrono::fixedzone]] for custom timezones.
// If specified (non-void), 'zo' must match one of the timezone's observed
// zoffs, or will fail. See [[time::chrono::fixedzone]] for custom timezones.
//
// You may omit the zoffset. If the givem timezone has a single zone, [[new]]
// will use that zone's zoffset. Otherwise [[new]] will try to infer the zoffset
// You may omit the zoff. If the givem timezone has a single zone, [[new]]
// will use that zone's zoff. Otherwise [[new]] will try to infer the zoff
// from the multiple zones. This will fail during certain timezone transitions,
// where certain datetimes are ambiguous or nonexistent. For example:
//
@@ -95,30 +136,29 @@ export fn new(
	// - Implement as described.
	// - fix calls with `years <= -4715`.
	//   https://todo.sr.ht/~sircmpwn/hare/565
	let defaults: [_]int = [
	let _fields: [_]int = [
		0, 1, 1,    // year month day
		0, 0, 0, 0, // hour min sec nsec
	];

	if (len(fields) > len(defaults)) {
		// cannot specify more than 7 fields
		return invalid;
	if (len(fields) > len(_fields)) {
		abort("datetime::new(): Too many field arguments");
	};

	for (let i = 0z; i < len(fields); i += 1) {
		defaults[i] = fields[i];
		_fields[i] = fields[i];
	};

	const year  = defaults[0];
	const month = defaults[1];
	const day   = defaults[2];
	const hour  = defaults[3];
	const min   = defaults[4];
	const sec   = defaults[5];
	const nsec  = defaults[6];
	const year  = _fields[0];
	const month = _fields[1];
	const day   = _fields[2];
	const hour  = _fields[3];
	const min   = _fields[4];
	const sec   = _fields[5];
	const nsec  = _fields[6];

	const mdate = calc_date_from_ymd(year, month, day)?;
	const mtime = calc_time_from_hmsn(hour, min, sec, nsec)?;
	const mdate = calc_date__ymd(year, month, day)?;
	const mtime = calc_time__hmsn(hour, min, sec, nsec)?;

	// create the moment
	const m = match (zo) {
@@ -134,33 +174,35 @@ export fn new(
	};

	const dt = from_moment(m);

	const zo = match (zo) {
	case void =>
		yield chrono::mzone(&m).zoff;
	case let d: time::duration =>
		yield d;
	};

	// check if input values are actually observed
	if (
		year == _year(&dt)
		&& month == _month(&dt)
		&& day == _day(&dt)
		&& hour == _hour(&dt)
		&& min == _minute(&dt)
		&& sec == _second(&dt)
		&& nsec == _nanosecond(&dt)
		zo       != chrono::mzone(&dt).zoff
		|| year  != _year(&dt)
		|| month != _month(&dt)
		|| day   != _day(&dt)
		|| hour  != _hour(&dt)
		|| min   != _minute(&dt)
		|| sec   != _second(&dt)
		|| nsec  != _nanosecond(&dt)
	) {
		void;
	} else {
		return invalid;
	};

	return dt;
};

// Returns a [[datetime]] of the current system time,
// using [[time::clock::REALTIME]] and [[time::chrono::LOCAL]].
// using [[time::clock::REALTIME]] and [[time::chrono::UTC]].
export fn now() datetime = {
	// TODO: Consider adding function parameters.
	// Should [[now]] specify appropriate params like a time::clock and
	// chrono::timezone? Perhaps a separate function, [[from_clock]].
	//
	// https://todo.sr.ht/~sircmpwn/hare/645
	const i = time::now(time::clock::REALTIME);
	const m = chrono::new(chrono::LOCAL, i);
	return from_moment(m);
	return from_instant(chrono::UTC, time::now(time::clock::REALTIME));
};

// Creates a [[datetime]] from a [[time::chrono::moment]].
@@ -181,104 +223,25 @@ export fn from_instant(loc: chrono::locality, i: time::instant) datetime = {
	return from_moment(chrono::new(loc, i));
};

// Creates a [[datetime]] from a string, parsed according to a layout,
// using [[strategy::ALL]], or otherwise fails.
export fn from_str(layout: str, s: str) (datetime | insufficient | invalid) = {
	// XXX: Should we allow the user to specify [[strategy]] for security?
	const b = newbuilder();
	parse(&b, layout, s)?;
	return finish(&b)?;
};

// A [[builder]] has insufficient information and cannot create a valid datetime.
export type insufficient = !void;

// A pseudo-datetime; a [[datetime]] which may hold invalid values, and does not
// guarantee internal validity or consistency.
// Creates a [[datetime]] from a string, parsed according to a layout format.
// See [[parse]] and [[format]].
//
// This can be used to construct new [[datetime]]s. Start with [[newbuilder]],
// then collect enough datetime information incrementally by direct field
// assignments and/or one or more calls to [[parse]]. Finish with [[finish]].
// 	let new = datetime::from_str(
// 		datetime::STAMP_NOZL,
// 		"2019-12-27 22:07:08.000000000 +0100 CET Europe/Amsterdam",
// 		locs...
// 	)!;
//
// 	let builder = datetime::newbuilder();
// 	datetime::parse(&builder, "Year: %Y", "Year: 2038");
// 	datetime::parse(&builder, "Month: %m", "Month: 01");
// 	builder.day = 19;
// 	let dt = datetime::finish(&builder, datetime::strategy::YMD);
//
export type builder = datetime;

// Creates a new [[builder]].
export fn newbuilder() builder = init(): builder;

// Returns a [[datetime]] from a [[builder]]. The provided [[strategy]]s will be
// tried in order until a valid datetime is produced, or otherwise fail. The
// default strategy is [[strategy::ALL]].
export fn finish(f: *builder, m: strategy...) (datetime | insufficient | invalid) = {
	if (len(m) == 0) {
		m = [strategy::ALL];
	};

	for (let i = 0z; i < len(m); i += 1) {
		const M = m[i];
		if (
			M & strategy::YMD != 0 &&
			f.year is int &&
			f.month is int &&
			f.day is int
		) {
			f.date = calc_date_from_ymd(
				f.year as int,
				f.month as int,
				f.day as int,
			)?;
			return *f: datetime;
		};

		if (
			M & strategy::YD != 0 &&
			f.year is int &&
			f.yearday is int
		) {
			f.date = calc_date_from_yd(
				f.year as int,
				f.yearday as int,
			)?;
			return *f: datetime;
		};

		if (
			M & strategy::YWD != 0 &&
			f.year is int &&
			f.week is int &&
			f.weekday is int
		) {
			f.date = calc_date_from_ywd(
				f.year as int,
				f.week as int,
				f.weekday as int,
			)?;
			return *f: datetime;
		};

		// TODO: calendar.ha: calc_date_from_isoywd()
	};

	return insufficient;
};

// Specifies which [[builder]] fields and what strategy to use to calculate the
// date, and thus a valid [[datetime]].
export type strategy = enum uint {
	// year, month, day
	YMD    = 1 << 0,
	// year, yearday
	YD     = 1 << 1,
	// year, week, weekday
	YWD    = 1 << 2,
	// isoyear, isoweek, weekday
	ISOYWD = 1 << 4,

	// all strategies, in order as presented here
	ALL    = YMD | YD | YWD | ISOYWD,
// The datetime's [[time::chrono::locality]] will be selected from the provided
// locality arguments. The 'name' field of these localities will be matched
// against the parsed result for the %L specifier. If %L is not specified, or if
// no locality is provided, [[time::chrono::UTC]] is used.
export fn from_str(
	layout: str,
	s: str,
	locs: time::chrono::locality...
) (datetime | parsefail | insufficient | invalid) = {
	const v = newvirtual();
	parse(&v, layout, s)?;
	return realize(v, locs...)?;
};
diff --git a/datetime/duration.ha b/datetime/duration.ha
new file mode 100644
index 00000000..1e02f1e0
--- /dev/null
+++ b/datetime/duration.ha
@@ -0,0 +1,11 @@
// License: MPL-2.0
// (c) 2023 Byron Torres <b@torresjrjr.com>
use time;

// Adds a [[time::duration]] to a [[datetime]] with [[time::add]].
//
// See [[reckon]] for a chronology-wise arithmetic operation which uses
// [[period]].
export fn add(a: datetime, d: time::duration) datetime = {
	return from_instant(a.loc, time::add(*(&a: *time::instant), d));
};
diff --git a/datetime/errors.ha b/datetime/errors.ha
new file mode 100644
index 00000000..354beddf
--- /dev/null
+++ b/datetime/errors.ha
@@ -0,0 +1,18 @@
// License: MPL-2.0
// (c) 2023 Byron Torres <b@torresjrjr.com>

// All possible errors returned from [[datetime]].
export type error = !(insufficient | invalid | parsefail);

// Converts an [[error]] into a human-friendly string.
export fn strerror(err: error) const str = {
	match (err) {
	case insufficient =>
		return "Insufficient datetime information";
	case invalid =>
		return "Invalid datetime information";
	case let rn: parsefail =>
		// TODO: use rune 'rn' here
		return "Datetime parsing error";
	};
};
diff --git a/datetime/format.ha b/datetime/format.ha
index 93645ef0..409b752f 100644
--- a/datetime/format.ha
+++ b/datetime/format.ha
@@ -6,9 +6,9 @@ use ascii;
use errors;
use fmt;
use io;
use strconv;
use strings;
use strio;
use time;
use time::chrono;

// [[datetime::format]] layout for the email date format.
@@ -32,6 +32,10 @@ export def STAMP: str = "%Y-%m-%d %H:%M:%S";
// [[datetime::format]] layout for a simple timestamp with nanoseconds.
export def STAMP_NANO: str = "%Y-%m-%d %H:%M:%S.%N";

// [[datetime::format]] layout for a simple timestamp with nanoseconds and zone
// offset.
export def STAMP_ZOFF: str = "%Y-%m-%d %H:%M:%S.%N %z";

// [[datetime::format]] layout for a simple timestamp with nanoseconds,
// zone offset, zone abbreviation, and locality.
export def STAMP_NOZL: str = "%Y-%m-%d %H:%M:%S.%N %z %Z %L";
@@ -83,7 +87,7 @@ export fn bsformat(
	buf: []u8,
	layout: str,
	dt: *datetime,
) (str | invalid | io::error) = {
) (str | io::error) = {
	let sink = strio::fixed(buf);
	format(&sink, layout, dt)?;
	return strio::string(&sink);
@@ -91,7 +95,7 @@ export fn bsformat(

// Formats a [[datetime]] and writes it into a heap-allocated string.
// The caller must free the return value.
export fn asformat(layout: str, dt: *datetime) (str | invalid | io::error) = {
export fn asformat(layout: str, dt: *datetime) (str | io::error) = {
	let sink = strio::dynamic();
	format(&sink, layout, dt)?;
	return strio::string(&sink);
@@ -100,70 +104,61 @@ export fn asformat(layout: str, dt: *datetime) (str | invalid | io::error) = {
fn fmtout(out: io::handle, r: rune, dt: *datetime) (size | io::error) = {
	switch (r) {
	case 'a' =>
		return fmt::fprint(out, WEEKDAYS_SHORT[weekday(dt) - 1]);
		return fmt::fprint(out, WEEKDAYS_SHORT[_weekday(dt)]);
	case 'A' =>
		return fmt::fprint(out, WEEKDAYS[weekday(dt) - 1]);
		return fmt::fprint(out, WEEKDAYS[_weekday(dt)]);
	case 'b' =>
		return fmt::fprint(out, MONTHS_SHORT[month(dt) - 1]);
		return fmt::fprint(out, MONTHS_SHORT[_month(dt) - 1]);
	case 'B' =>
		return fmt::fprint(out, MONTHS[month(dt) - 1]);
		return fmt::fprint(out, MONTHS[_month(dt) - 1]);
	case 'd' =>
		return fmt::fprintf(out, "{:02}", day(dt));
		return fmt::fprintf(out, "{:02}", _day(dt));
	case 'F' =>
		return fmt::fprintf(out, "{:04}-{:02}-{:02}", year(dt), month(dt), day(dt));
		return fmt::fprintf(out, "{:04}-{:02}-{:02}", _year(dt), _month(dt), _day(dt));
	case 'H' =>
		return fmt::fprintf(out, "{:02}", hour(dt));
		return fmt::fprintf(out, "{:02}", _hour(dt));
	case 'I' =>
		return fmt::fprintf(out, "{:02}", hour12(dt));
		return fmt::fprintf(out, "{:02}", (_hour(dt) + 11) % 12 + 1);
	case 'j' =>
		return fmt::fprint(out, strconv::itos(yearday(dt)));
		return fmt::fprintf(out, "{:03}", _yearday(dt));
	case 'L' =>
		return fmt::fprint(out, dt.loc.name);
	case 'm' =>
		return fmt::fprintf(out, "{:02}", month(dt));
		return fmt::fprintf(out, "{:02}", _month(dt));
	case 'M' =>
		return fmt::fprintf(out, "{:02}", minute(dt));
		return fmt::fprintf(out, "{:02}", _minute(dt));
	case 'N' =>
		return fmt::fprintf(out, "{:09}", strconv::itos(nanosecond(dt)));
		return fmt::fprintf(out, "{:09}", _nanosecond(dt));
	case 'p' =>
		const s = if (hour(dt) < 12) {
			yield "AM";
		} else {
			yield "PM";
		};
		return fmt::fprint(out, s);
		return fmt::fprint(out, if (_hour(dt) < 12) "AM" else "PM");
	case 's' =>
		return fmt::fprintf(out, "{:02}", epochunix(dt));
		return fmt::fprintf(out, "{:02}", time::unix(*(dt: *time::instant)));
	case 'S' =>
		return fmt::fprintf(out, "{:02}", second(dt));
		return fmt::fprintf(out, "{:02}", _second(dt));
	case 'T' =>
		return fmt::fprintf(out, "{:02}:{:02}:{:02}", hour(dt), minute(dt), second(dt));
		return fmt::fprintf(out, "{:02}:{:02}:{:02}", _hour(dt), _minute(dt), _second(dt));
	case 'u' =>
		return fmt::fprint(out, strconv::itos(weekday(dt)));
		return fmt::fprintf(out, "{}", _weekday(dt) + 1);
	case 'U' =>
		return fmt::fprintf(out, "{:02}", _sundayweek(dt));
	case 'w' =>
		return fmt::fprint(out, strconv::itos(weekday(dt) % 7));
		return fmt::fprintf(out, "{}", (_weekday(dt) + 1) % 7);
	case 'W' =>
		return fmt::fprintf(out, "{:02}", week(dt));
		return fmt::fprintf(out, "{:02}", _week(dt));
	case 'y' =>
		let year_str = strconv::itos(year(dt));
		year_str = strings::sub(year_str, len(year_str) - 2, strings::end);
		return fmt::fprint(out, year_str);
		return fmt::fprintf(out, "{:02}", _year(dt) % 100);
	case 'Y' =>
		return fmt::fprint(out, strconv::itos(year(dt)));
		return fmt::fprintf(out, "{:04}", _year(dt));
	case 'z' =>
		// TODO: test me
		let pm = '+';
		const z = if (chrono::getzone(dt).zoffset >= 0) {
			yield calc_hmsn(chrono::getzone(dt).zoffset);
		const (sign, zo) = if (chrono::mzone(dt).zoff >= 0) {
			yield ('+', calc_hmsn(chrono::mzone(dt).zoff));
		} else {
			pm = '-';
			yield calc_hmsn(-chrono::getzone(dt).zoffset);
			yield ('-', calc_hmsn(-chrono::mzone(dt).zoff));
		};
		return fmt::fprintf(out, "{}{:02}{:02}", pm, z.0, z.1);
		const (hr, mi) = (zo.0, zo.1);
		return fmt::fprintf(out, "{}{:02}{:02}", sign, hr, mi);
	case 'Z' =>
		return fmt::fprint(out, chrono::getzone(dt).abbr);
		return fmt::fprint(out, chrono::mzone(dt).abbr);
	case '%' =>
		return fmt::fprint(out, "%");
	case =>
@@ -212,7 +207,7 @@ export fn format(
	h: io::handle,
	layout: str,
	dt: *datetime
) (size | invalid | io::error) = {
) (size | io::error) = {
	const iter = strings::iter(layout);
	let escaped = false;
	let n = 0z;
@@ -238,85 +233,6 @@ export fn format(
	return n;
};

fn get_default_locale_string_index(iter: *strings::iterator, list: []str) (int | invalid) = {
	const name = strings::iterstr(iter);
	if (len(name) == 0) {
		return invalid;
	};
	for(let i = 0z; i < len(list); i += 1) {
		if (strings::hasprefix(name, list[i])) {
			// Consume name
			for (let j = 0z; j < len(list[i]); j += 1) {
				strings::next(iter);
			};
			return (i: int) + 1;
		};
	};
	return invalid;
};

fn get_max_n_digits(iter: *strings::iterator, n: uint) (int | invalid) = {
	let buf: [64]u8 = [0...];
	let bufstr = strio::fixed(buf);
	for (let i = 0z; i < n; i += 1) {
		let r: rune = match (strings::next(iter)) {
			case void =>
				break;
			case let r: rune =>
				yield r;
		};
		if (!ascii::isdigit(r)) {
			strings::prev(iter);
			break;
		};
		match (strio::appendrune(&bufstr, r)) {
		case io::error =>
			return invalid;
		case =>
			void;
		};
	};
	return match (strconv::stoi(strio::string(&bufstr))) {
	case let res: int =>
		yield res;
	case =>
		yield invalid;
	};
};

fn eat_one_rune(iter: *strings::iterator, needle: rune) (uint | invalid) = {
	let s_r = match (strings::next(iter)) {
	case void =>
		return invalid;
	case let r: rune =>
		yield r;
	};
	if (s_r == needle) {
		return 1;
	} else {
		strings::prev(iter);
		return 0;
	};
};

fn clamp_int(i: int, min: int, max: int) int = {
	return if (i < min) {
		yield min;
	} else if (i > max) {
		yield max;
	} else {
		yield i;
	};
};

fn hour12(dt: *datetime) int = {
	let mod_hour = hour(dt) % 12;
	if (mod_hour == 0) {
		mod_hour = 12;
	};
	return mod_hour;
};

@test fn format() void = {
	const dt = new(chrono::UTC, 0, 1994, 1, 1, 2, 17, 5, 24)!;

@@ -350,7 +266,7 @@ fn hour12(dt: *datetime) int = {
		("%a", "Sat"),
		("%A", "Saturday"),
		// yearday
		("%j", "1"),
		("%j", "001"),
		// week
		("%W", "00"),
		// full date
@@ -375,80 +291,3 @@ fn hour12(dt: *datetime) int = {
		};
	};
};

// TODO: Refactor this once the rest of the parse() refactoring is done
// Ticket: https://todo.sr.ht/~sircmpwn/hare/648

// @test fn parse() void = {
// 	let dt = datetime {...};

// 	// General tests
// 	parse("%Y-%m-%d %H:%M:%S.%N", "1994-08-27 11:01:02.123", &dt)!;
// 	assert(dt.year as int == 1994 &&
// 		dt.month as int == 8 &&
// 		dt.day as int == 27 &&
// 		dt.hour as int == 11 &&
// 		dt.min as int == 1 &&
// 		dt.sec as int == 2 &&
// 		dt.nsec as int == 123, "invalid parsing results");

// 	// General errors
// 	assert(parse("%Y-%m-%d", "1a94-08-27", &dt) is invalid,
// 		"invalid datetime string did not throw error");

// 	assert(parse("%Y-%m-%d", "1994-123-27", &dt) is invalid,
// 		"invalid datetime string did not throw error");

// 	assert(parse("%Y-%m-%d", "a994-08-27", &dt) is invalid,
// 		"invalid datetime string did not throw error");

// 	// Basic specifiers
// 	parse("%a", "Tue", &dt)!;
// 	assert(dt.weekday as int == 2, "invalid parsing results");

// 	parse("%a %d", "Tue 27", &dt)!;
// 	assert(dt.weekday as int == 2 &&
// 		dt.day as int == 27, "invalid parsing results");

// 	parse("%A", "Tuesday", &dt)!;
// 	assert(dt.weekday as int == 2, "invalid parsing results");

// 	parse("%b", "Feb", &dt)!;
// 	assert(dt.month as int == 2, "invalid parsing results");

// 	parse("%B", "February", &dt)!;
// 	assert(dt.month as int == 2, "invalid parsing results");

// 	parse("%I", "14", &dt)!;
// 	assert(dt.hour as int == 2, "invalid parsing results");

// 	parse("%j", "123", &dt)!;
// 	assert(dt.yearday as int == 123, "invalid parsing results");

// 	parse("%H %p", "6 AM", &dt)!;
// 	assert(dt.hour as int == 6, "invalid parsing results");

// 	parse("%H %p", "6 PM", &dt)!;
// 	assert(dt.hour as int == 18, "invalid parsing results");

// 	assert(parse("%H %p", "13 PM", &dt) is invalid,
// 		"invalid parsing results");

// 	assert(parse("%H %p", "PM 6", &dt) is invalid,
// 		"invalid parsing results");

// 	parse("%u", "7", &dt)!;
// 	assert(dt.weekday as int == 7, "invalid parsing results");

// 	parse("%U", "2", &dt)!;
// 	assert(dt.week as int == 2, "invalid parsing results");

// 	parse("%U", "99", &dt)!;
// 	assert(dt.week as int == 53, "invalid parsing results");

// 	parse("%w", "0", &dt)!;
// 	assert(dt.weekday as int == 7, "invalid parsing results");

// 	parse("%W", "2", &dt)!;
// 	assert(dt.week as int == 2, "invalid parsing results");
// };
diff --git a/datetime/parse.ha b/datetime/parse.ha
index 0a0d45e5..bdbb7b42 100644
--- a/datetime/parse.ha
+++ b/datetime/parse.ha
@@ -2,175 +2,403 @@
// (c) 2021-2022 Byron Torres <b@torresjrjr.com>
// (c) 2022 Drew DeVault <sir@cmpwn.com>
// (c) 2021-2022 Vlad-Stefan Harbuz <vlad@vladh.net>
use ascii;
use errors;
use io;
use strconv;
use strings;
use strio;
use time;
use time::chrono;

// Parses a date/time string into a [[builder]], according to a layout format
type failure = !void;

// A parsing error occurred. If appropriate, the offending format specifier is
// stored. A null rune represents all other error cases.
export type parsefail = !rune;

// Parses a date/time string into a [[virtual]], according to a layout format
// string with specifiers as documented under [[format]]. Partial, sequential,
// aggregative parsing is possible.
//
// 	datetime::parse(&builder, "%Y-%m-%d", "2038-01-19");
// 	datetime::parse(&builder, "%H:%M:%S", "03:14:07");
// 	datetime::parse(&v, "%Y-%m-%d",    "2019-12-27");
// 	datetime::parse(&v, "%H:%M:%S.%N", "22:07:08.000000000");
// 	datetime::parse(&v, "%z %Z %L",    "+0100 CET Europe/Amsterdam");
//
export fn parse(build: *builder, layout: str, s: str) (void | invalid) = {
	const format_iter = strings::iter(layout);
	const s_iter = strings::iter(s);
export fn parse(v: *virtual, layout: str, s: str) (void | parsefail) = {
	const liter = strings::iter(layout);
	const siter = strings::iter(s);
	let escaped = false;

	for (true) {
		let format_r: rune = match (strings::next(&format_iter)) {
		const lr: rune = match (strings::next(&liter)) {
		case void =>
			break;
		case let r: rune =>
			yield r;
		case let lr: rune =>
			yield lr;
		};

		if (!escaped && format_r == '%') {
		if (!escaped && lr == '%') {
			escaped = true;
			continue;
		};

		if (!escaped) {
			let s_r = match (strings::next(&s_iter)) {
			const sr = match (strings::next(&siter)) {
			case void =>
				return invalid;
			case let r: rune =>
				yield r;
				return '\x00';
			case let sr: rune =>
				yield sr;
			};
			if (s_r != format_r) {
				return invalid;
			if (sr != lr) {
				return '\x00';
			};
			continue;
		};

		escaped = false;
		switch (format_r) {
		// Basic specifiers
		case 'a' =>
			build.weekday = get_default_locale_string_index(
				&s_iter, WEEKDAYS_SHORT[..])?;
		case 'A' =>
			build.weekday = get_default_locale_string_index(
				&s_iter, WEEKDAYS[..])?;
		case 'b' =>
			build.month = get_default_locale_string_index(
				&s_iter, MONTHS_SHORT[..])?;
		case 'B' =>
			build.month = get_default_locale_string_index(
				&s_iter, MONTHS[..])?;
		case 'd' =>
			let max_n_digits = 2u;
			build.day = clamp_int(
				get_max_n_digits(&s_iter, max_n_digits)?, 1, 31);
		case 'H' =>
			let max_n_digits = 2u;
			build.hour = clamp_int(
				get_max_n_digits(&s_iter, max_n_digits)?, 0, 23);
		case 'I' =>
			let max_n_digits = 2u;
			const hour = get_max_n_digits(&s_iter, max_n_digits);
			build.hour = match (hour) {
			case let hour: int =>
				yield if (hour > 12) {
					yield clamp_int(hour - 12, 1, 12);
				} else {
					yield clamp_int(hour, 1, 12);
				};
			case =>
				return invalid;
			};
		case 'j' =>
			build.yearday = clamp_int(
				get_max_n_digits(&s_iter, 3)?, 1, 366);
		case 'L' =>
			// TODO: Parse %L (locality/timezone name/ID).
			continue;
		case 'm' =>
			build.month = clamp_int(
				get_max_n_digits(&s_iter, 2)?, 1, 12);
		case 'M' =>
			build.minute = clamp_int(
				get_max_n_digits(&s_iter, 2)?, 0, 59);
		case 'N' =>
			build.nanosecond = clamp_int(
				get_max_n_digits(&s_iter, 9)?, 0, 999999999);
		case 'p' =>
			if (build.hour is void) {
				// We can't change the hour's am/pm because we
				// have no hour.
				return invalid;
			};
			const rest = strings::iterstr(&s_iter);
			if (strings::hasprefix(rest, "AM")) {
				if (build.hour as int > 12) {
					// 13 AM?
					return invalid;
				} else if (build.hour as int == 12) {
					build.hour = 0;
				};
			} else if (strings::hasprefix(rest, "PM")) {
				if (build.hour as int > 12) {
					// 13 PM?
					return invalid;
				} else if (build.hour as int < 12) {
					build.hour =
						(build.hour as int) + 12;
				};
			} else {
				return invalid;
			};
			strings::next(&s_iter);
			strings::next(&s_iter);
		case 'S' =>
			build.second = clamp_int(
				get_max_n_digits(&s_iter, 2)?, 0, 61);
		case 'u', 'w' =>
			build.weekday = match (get_max_n_digits(&s_iter, 1)) {
			case let i: int =>
				yield if (format_r == 'w') {
					yield if (i == 0) {
						yield 7;
					} else {
						yield clamp_int(i, 1, 7);
					};
				} else {
					yield clamp_int(i, 1, 7);
				};
			case =>
				return invalid;
			};
		case 'U', 'W' =>
			build.week = clamp_int(
				get_max_n_digits(&s_iter, 2)?, 0, 53);
		case 'Y' =>
			build.year = get_max_n_digits(&s_iter, 4)?;
		case 'z' =>
			const rest = strings::iterstr(&s_iter);
			if(strings::hasprefix(rest, 'Z') || strings::hasprefix(rest, 'z')) {
				(build.zone: chrono::zone).zoffset = 0;
			} else {
				const prefix = strings::next(&s_iter);
				(build.zone: chrono::zone).zoffset = get_max_n_digits(&s_iter, 2)? * time::HOUR;

				const rest = strings::iterstr(&s_iter);
				if(strings::hasprefix(rest, ":")) {
					strings::next(&s_iter);
				};

				(build.zone: chrono::zone).zoffset += get_max_n_digits(&s_iter, 2)? * time::MINUTE;

				if(prefix == '-') {
					(build.zone: chrono::zone).zoffset *= -1;
				};

		match (parse_specifier(v, &siter, lr)) {
		case void => void;
		case failure =>
			return lr;
		};
	};

	return void;
};

fn parse_specifier(
	v: *virtual,
	iter: *strings::iterator,
	lr: rune,
) (void | failure) = {
	switch (lr) {
	case 'a' => v.weekday =
		scan_for(iter, WEEKDAYS_SHORT...)?;
	case 'A' => v.weekday =
		scan_for(iter, WEEKDAYS...)?;
	case 'b' => v.month =
		scan_for(iter, MONTHS_SHORT...)? + 1;
	case 'B' => v.month =
		scan_for(iter, MONTHS...)? + 1;
	case 'd' => v.day =
		scan_int(iter, 2, false)?;
	case 'H' => v.hour =
		scan_int(iter, 2, false)?;
	case 'I' => v.halfhour =
		scan_int(iter, 2, false)?;
	case 'j' => v.yearday =
		scan_int(iter, 3, false)?;
	case 'L' => v.locname =
		scan_str(iter)?;
	case 'm' => v.month =
		scan_int(iter, 2, false)?;
	case 'M' => v.minute =
		scan_int(iter, 2, false)?;
	case 'N' => v.nanosecond =
		scan_int(iter, 9, true)?;
	case 'p' => v.ampm = // AM=false PM=true
		scan_for(iter, "AM", "PM", "am", "pm")? % 2 == 1;
	case 'S' => v.second =
		scan_int(iter, 2, false)?;
	case 'u' => v.weekday =
		scan_int(iter, 1, false)? - 1;
	case 'U' => v.week =
		scan_int(iter, 2, false)?;
	case 'w' => v.weekday =
		scan_int(iter, 1, false)? - 1;
	case 'W' => v.week =
		scan_int(iter, 2, false)?;
	case 'Y' => v.year =
		scan_int(iter, 4, false)?;
	case 'z' => v.zoff =
		scan_zo(iter)?;
	case 'Z' => v.zabbr =
		scan_str(iter)?;
	case '%' =>
		eat_rune(iter, '%')?;
	case =>
		void; // Ignore invalid specifier
	};
};

fn eat_rune(iter: *strings::iterator, needle: rune) (uint | failure) = {
	const rn = match (strings::next(iter)) {
	case void =>
		return failure;
	case let rn: rune =>
		yield rn;
	};
	if (rn == needle) {
		return 1;
	} else {
		strings::prev(iter);
		return 0;
	};
};

// Scans the iterator for a given list of strings.
// Returns the list index of the matched string.
fn scan_for(iter: *strings::iterator, list: str...) (int | failure) = {
	const name = strings::iterstr(iter);
	if (len(name) == 0) {
		return failure;
	};
	for(let i = 0z; i < len(list); i += 1) {
		if (strings::hasprefix(name, list[i])) {
			// Consume name
			for (let j = 0z; j < len(list[i]); j += 1) {
				strings::next(iter);
			};
		case '%' =>
			eat_one_rune(&s_iter, '%')?;
			return i: int;
		};
	};
	return failure;
};

// Scans the iterator upto n consecutive numeric digits.
// Returns the resulting int.
// If pad is true, the number is right-padded with zeroes upto n digits.
fn scan_int(iter: *strings::iterator, n: size, pad: bool) (int | failure) = {
	let buf: [64]u8 = [0...];
	let bufstr = strio::fixed(buf);
	for (let i = 0z; i < n; i += 1) {
		let rn: rune = match (strings::next(iter)) {
		case void =>
			break;
		case let rn: rune =>
			yield rn;
		};
		if (!ascii::isdigit(rn)) {
			strings::prev(iter);
			break;
		};
		match (strio::appendrune(&bufstr, rn)) {
		case io::error =>
			return failure;
		case =>
			// Ignore invalid specifier
			continue;
			void;
		};
	};
	return void;
	const s = strings::padend(strio::string(&bufstr), '0', if (pad) n else 0);
	defer free(s);

	match (strconv::stoi(s)) {
	case let n: int =>
		return n;
	case =>
		return failure;
	};
};

// Scans and parses zone offsets of the form:
//
// 	Z
// 	z
// 	+nn:nn
// 	-nn:nn
//
fn scan_zo(iter: *strings::iterator) (time::duration | failure) = {
	const rest = strings::iterstr(iter);
	if (strings::hasprefix(rest, 'Z') || strings::hasprefix(rest, 'z')) {
		return 0;
	} else {
		const prefix = strings::next(iter);
		let zo = scan_int(iter, 2, false)? * time::HOUR;
		const rest = strings::iterstr(iter);
		if (strings::hasprefix(rest, ":")) {
			strings::next(iter);
		};
		zo += scan_int(iter, 2, false)? * time::MINUTE;
		if (prefix == '-') {
			zo *= -1;
		};
		return zo;
	};
};

// Scans and parses locality names, made of printable characters.
fn scan_str(iter: *strings::iterator) (str | failure) = {
	static let buf: [64]u8 = [0...];
	let bufstr = strio::fixed(buf);
	for (true) {
		let rn: rune = match (strings::next(iter)) {
		case void =>
			break;
		case let rn: rune =>
			yield rn;
		};
		if (!ascii::isgraph(rn)) {
			strings::prev(iter);
			break;
		};
		strio::appendrune(&bufstr, rn)!;
	};
	return strio::string(&bufstr);
};

@test fn parse() void = {
	let v = newvirtual();
	assert(parse(&v, "foo", "foo") is void, "none: parsefail");
	assert(v.zone        == null, "none: non-null zone");
	assert(v.date        is void, "none: non-void date");
	assert(v.time        is void, "none: non-void time");
	assert(v.era         is void, "none: non-void era");
	assert(v.year        is void, "none: non-void year");
	assert(v.month       is void, "none: non-void month");
	assert(v.day         is void, "none: non-void day");
	assert(v.yearday     is void, "none: non-void yearday");
	assert(v.isoweekyear is void, "none: non-void isoweekyear");
	assert(v.isoweek     is void, "none: non-void isoweek");
	assert(v.week        is void, "none: non-void week");
	assert(v.sundayweek  is void, "none: non-void sundayweek");
	assert(v.weekday     is void, "none: non-void weekday");
	assert(v.hour        is void, "none: non-void hour");
	assert(v.minute      is void, "none: non-void minute");
	assert(v.second      is void, "none: non-void second");
	assert(v.nanosecond  is void, "none: non-void nanosecond");
	assert(v.vloc        is void, "none: non-void vloc");
	assert(v.locname     is void, "none: non-void locname");
	assert(v.zoff        is void, "none: non-void zoff");
	assert(v.zabbr       is void, "none: non-void zabbr");
	assert(v.halfhour    is void, "none: non-void halfhour");
	assert(v.ampm        is void, "none: non-void ampm");

	let v = newvirtual();
	assert(parse(&v, "%a", "Fri") is void                , "%a: parsefail");
	assert(v.weekday is int                              , "%a: void");
	assert(v.weekday as int == 4                         , "%a: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%A", "Friday") is void             , "%A: parsefail");
	assert(v.weekday is int                              , "%A: void");
	assert(v.weekday as int == 4                         , "%A: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%b", "Jan") is void                , "%b: parsefail");
	assert(v.month is int                                , "%b: void");
	assert(v.month as int == 1                           , "%b: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%B", "January") is void   ,         "%B: parsefail");
	assert(v.month is int                                , "%B: void");
	assert(v.month as int == 1                           , "%B: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%d", "27") is void                 , "%d: parsefail");
	assert(v.day is int                                  , "%d: void");
	assert(v.day as int == 27                            , "%d: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%H", "22") is void                 , "%H: parsefail");
	assert(v.hour is int                                 , "%H: void");
	assert(v.hour as int == 22                           , "%H: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%I", "10") is void                 , "%I: parsefail");
	assert(v.halfhour is int                             , "%I: void");
	assert(v.halfhour as int == 10                       , "%I: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%j", "361") is void                , "%j: parsefail");
	assert(v.yearday is int                              , "%j: void");
	assert(v.yearday as int == 361                       , "%j: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%L", "Europe/Amsterdam") is void   , "%L: parsefail");
	assert(v.locname is str                              , "%L: void");
	assert(v.locname as str == "Europe/Amsterdam"        , "%L: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%m", "12") is void                 , "%m: parsefail");
	assert(v.month is int                                , "%m: void");
	assert(v.month as int == 12                          , "%m: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%M", "07") is void                 , "%M: parsefail");
	assert(v.minute is int                               , "%M: void");
	assert(v.minute as int == 7                          , "%M: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%N", "123456789") is void          , "%N: parsefail");
	assert(v.nanosecond is int                           , "%N: void");
	assert(v.nanosecond as int == 123456789              , "%N: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%p", "PM") is void                 , "%p: parsefail");
	assert(v.ampm is bool                                , "%p: void");
	assert(v.ampm as bool == true                        , "%p: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%S", "08") is void                 , "%S: parsefail");
	assert(v.second is int                               , "%S: void");
	assert(v.second as int == 8                          , "%S: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%u", "5") is void                  , "%u: parsefail");
	assert(v.weekday is int                              , "%u: void");
	assert(v.weekday as int == 4                         , "%u: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%U", "51") is void                 , "%U: parsefail");
	assert(v.week is int                                 , "%U: void");
	assert(v.week as int == 51                           , "%U: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%w", "5") is void                  , "%w: parsefail");
	assert(v.weekday is int                              , "%w: void");
	assert(v.weekday as int == 4                         , "%w: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%W", "51") is void                 , "%W: parsefail");
	assert(v.week is int                                 , "%W: void");
	assert(v.week as int == 51                           , "%W: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%Y", "2019") is void               , "%Y: parsefail");
	assert(v.year is int                                 , "%Y: void");
	assert(v.year as int == 2019                         , "%Y: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%z", "+0100") is void              , "%z: parsefail");
	assert(v.zoff is i64                                 , "%z: void");
	assert(v.zoff as i64 == 1 * time::HOUR               , "%z: incorrect");

	let v = newvirtual();
	assert(parse(&v, "%Z", "CET") is void                , "%Z: parsefail");
	assert(v.zabbr is str                                , "%Z: void");
	assert(v.zabbr as str == "CET"                       , "%Z: incorrect");

	let v = newvirtual();
	assert((
		parse(&v,
			"%Y-%m-%d %H:%M:%S.%N %z %Z %L",
			"2038-01-19 03:14:07.000000000 +0000 UTC UTC",
		)
		is void
	),
		"test 1: parsefail"
	);
	assert(v.year       is int         , "test 1: year void");
	assert(v.year       as int ==  2038, "test 1: year incorrect");
	assert(v.month      is int         , "test 1: month void");
	assert(v.month      as int ==     1, "test 1: month incorrect");
	assert(v.day        is int         , "test 1: day void");
	assert(v.day        as int ==    19, "test 1: day incorrect");
	assert(v.hour       is int         , "test 1: hour void");
	assert(v.hour       as int ==     3, "test 1: hour incorrect");
	assert(v.minute     is int         , "test 1: minute void");
	assert(v.minute     as int ==    14, "test 1: minute incorrect");
	assert(v.second     is int         , "test 1: second void");
	assert(v.second     as int ==     7, "test 1: second incorrect");
	assert(v.nanosecond is int         , "test 1: nanosecond void");
	assert(v.nanosecond as int ==     0, "test 1: nanosecond incorrect");
	assert(v.zoff       is i64         , "test 1: zoff void");
	assert(v.zoff       as i64 ==     0, "test 1: zoff incorrect");
	assert(v.zabbr      is str         , "test 1: zabbr void");
	assert(v.zabbr      as str == "UTC", "test 1: zabbr incorrect");
	assert(v.locname    is str         , "test 1: locname void");
	assert(v.locname    as str == "UTC", "test 1: locname incorrect");

};
diff --git a/datetime/period.ha b/datetime/period.ha
new file mode 100644
index 00000000..d0c8bc8d
--- /dev/null
+++ b/datetime/period.ha
@@ -0,0 +1,69 @@
// License: MPL-2.0
// (c) 2023 Byron Torres <b@torresjrjr.com>

// Represents a span of time in the Gregorian chronology, using nominal units of
// time. Used for chronological arithmetic.
export type period = struct {
	years: i64,
	months: i64,
	weeks: i64,
	days: i64,
	hours: i64,
	minutes: i64,
	seconds: i64,
	nanoseconds: i64,
};

// Returns true if two [[period]]s are numerically equal, false otherwise.
export fn peq(pa: period, pb: period) bool = {
	return (
		pa.years          == pb.years
		&& pa.months      == pb.months
		&& pa.weeks       == pb.weeks
		&& pa.days        == pb.days
		&& pa.hours       == pb.hours
		&& pa.minutes     == pb.minutes
		&& pa.seconds     == pb.seconds
		&& pa.nanoseconds == pb.nanoseconds
	);
};

// Returns the sum [[period]] of a set of periods.
export fn sum(ps: period...) period = {
	let p = period { ... };
	for (let i = 0z; i < len(ps); i += 1) {
		p.years       += ps[i].years;
		p.months      += ps[i].months;
		p.weeks       += ps[i].weeks;
		p.days        += ps[i].days;
		p.hours       += ps[i].hours;
		p.minutes     += ps[i].minutes;
		p.seconds     += ps[i].seconds;
		p.nanoseconds += ps[i].nanoseconds;
	};
	return p;
};

// Returns a [[period]] with its fields negated.
export fn neg(p: period) period = period {
	years       = -p.years,
	months      = -p.months,
	weeks       = -p.weeks,
	days        = -p.days,
	hours       = -p.hours,
	minutes     = -p.minutes,
	seconds     = -p.seconds,
	nanoseconds = -p.nanoseconds,
};

// Returns a [[period]] with its fields made absolute and positive.
export fn abs(p: period) period = period {
	years       = if (p.years < 0)       -p.years       else p.years,
	months      = if (p.months < 0)      -p.months      else p.months,
	weeks       = if (p.weeks < 0)       -p.weeks       else p.weeks,
	days        = if (p.days < 0)        -p.days        else p.days,
	hours       = if (p.hours < 0)       -p.hours       else p.hours,
	minutes     = if (p.minutes < 0)     -p.minutes     else p.minutes,
	seconds     = if (p.seconds < 0)     -p.seconds     else p.seconds,
	nanoseconds = if (p.nanoseconds < 0) -p.nanoseconds else p.nanoseconds,
};
diff --git a/datetime/reckon.ha b/datetime/reckon.ha
new file mode 100644
index 00000000..ef4031f1
--- /dev/null
+++ b/datetime/reckon.ha
@@ -0,0 +1,489 @@
// License: MPL-2.0
// (c) 2023 Byron Torres <b@torresjrjr.com>
use time;
use time::chrono;

// Specifies the behaviour of [[reckon]] when doing chronological arithmetic.
//
// The FLOOR, CEIL, HOP, and FOLD specifies how to resolve sub-significant
// overflows -- when a field's change in value causes any sub-significant
// field's range to shrink below its current value and become invalid. For
// example, adding 1 month to January 31st results in February 31st, a date with
// an unresolved day field, since February permits only 28 or 29 days.
export type calculus = enum uint {
	// The default behaviour. Equivalent to CEIL.
	DEFAULT = 0,

	// Apply units in reverse order, from least to most significant.
	REVSIG = 1 << 0,

	// When a sub-significant overflow occurs, the unresolved field is set
	// to its mininum valid value.
	//
	//     Feb 31 -> Feb 01
	//     Aug 64 -> Aug 01
	FLOOR = 1 << 1,

	// When a sub-significant overflow occurs, the unresolved field is set
	// to its maximum valid value.
	//
	//     Feb 31 -> Feb 28 / Feb 29   (leap year dependent)
	//     Aug 64 -> Aug 31
	CEIL = 1 << 2,

	// When a sub-significant overflow occurs, the unresolved field is set
	// to its new mininum valid value after the next super-significant field
	// increments by one.
	//
	//     Feb 31 -> Mar 01
	//     Aug 64 -> Sep 01
	HOP = 1 << 3,

	// When a sub-significant overflow occurs, the unresolved field's
	// maximum valid value is subtracted from it's current value, and the
	// next super-significant field increments by one. This process repeats
	// until the unresolved field's value becomes valid (falls in range).
	//
	//     Feb 31 -> Mar 03 / Mar 02   (leap year dependent)
	//     Aug 64 -> Sep 33 -> Oct 03
	FOLD = 1 << 4,
};

// Reckons from a given [[datetime]] to a new one, via a set of [[period]]s.
// This is a chronology-wise arithmetic operation. Each period is reckoned
// independently in succession, applying (adding) their units from most to least
// significant.
//
// The [[calculus]] parameter determines arithmetic and resolution behaviour
// when encountering deviations (e.g. overflows).
//
// 	let dest = datetime::reckon(
// 		start, // 2000-02-29 09:00:00
// 		0,     // calculus::DEFAULT
// 		datetime::period {
// 			years  =  1, // becomes: 2001-02-28 09:00:00
// 			months = -2, // becomes: 2000-12-28 09:00:00
// 			days   =  4, // becomes: 2001-01-01 09:00:00
// 		},
// 	);
//
// See [[add]] for a timescale-wise arithmetic operation which uses
// [[time::duration]].
export fn reckon(dt: datetime, calc: calculus, ps: period...) datetime = {
	let r = newvirtual(); // our reckoner
	r.vloc       = dt.loc;
	r.zoff       = chrono::mzone(&dt).zoff;
	r.year       = _year(&dt);
	r.month      = _month(&dt);
	r.day        = _day(&dt);
	r.hour       = _hour(&dt);
	r.minute     = _minute(&dt);
	r.second     = _second(&dt);
	r.nanosecond = _nanosecond(&dt);

	if (calc == calculus::DEFAULT) {
		calc |= calculus::CEIL;
	};

	for (let i = 0z; i < len(ps); i += 1) if (calc & calculus::REVSIG == 0) {
		const p = ps[i];
		const fold = calculus::FOLD;

		r.year = r.year as int + p.years: int;
		reckon_days(&r, 0, calc); // bubble up potential Feb 29 overflow

		reckon_months(&r, p.months);
		reckon_days(&r,   0, calc); // bubble up potential overflows

		reckon_days(&r, p.weeks * 7, fold);
		reckon_days(&r, p.days,      fold);

		// TODO: These functions aren't aware of top-down overflows.
		// Handle overflows (e.g. [[zone]] changes).
		reckon_hours(&r,       p.hours,       fold);
		reckon_minutes(&r,     p.minutes,     fold);
		reckon_seconds(&r,     p.seconds,     fold);
		reckon_nanoseconds(&r, p.nanoseconds, fold);
	} else {
		const p = ps[i];
		const fold = calculus::FOLD | calculus::REVSIG;

		reckon_nanoseconds(&r, p.nanoseconds, fold);
		reckon_seconds(&r,     p.seconds,     fold);
		reckon_minutes(&r,     p.minutes,     fold);
		reckon_hours(&r,       p.hours,       fold);
		reckon_days(&r,        p.days,        fold);
		reckon_days(&r,        p.weeks * 7,   fold);

		reckon_months(&r, p.months);
		reckon_days(&r,   0, calc); // bubble up potential overflows

		r.year = r.year as int + p.years: int;
		reckon_days(&r, 0, calc); // bubble up potential Feb 29 overflow
	};

	return realize(r)!;
};

fn reckon_months(r: *virtual, months: i64) void = {
	let year  = r.year  as int;
	let month = r.month as int;

	month += months: int;

	// month overflow
	for (month > 12) {
		month -= 12;
		year  += 1;
	};
	for (month < 1) {
		month += 12;
		year  -= 1;
	};

	r.year  = year;
	r.month = month;
};

fn reckon_days(r: *virtual, days: i64, calc: calculus) void = {
	let year  = r.year  as int;
	let month = r.month as int;
	let day   = r.day   as int;

	day += days: int;

	// day overflow
	let month_daycnt = calc_month_daycnt(year, month);
	for (day > month_daycnt) {
		if (calc & calculus::FLOOR != 0) {
			day = 1;
		} else if (calc & calculus::CEIL != 0) {
			day = month_daycnt;
		} else if (calc & calculus::HOP != 0) {
			r.year  = year;
			r.month = month;

			reckon_months(r, 1);

			year  = r.year  as int;
			month = r.month as int;
			day   = 1;
		} else if (calc & calculus::FOLD != 0) {
			r.year  = year;
			r.month = month;

			reckon_months(r, 1);

			year   = r.year  as int;
			month  = r.month as int;
			day   -= month_daycnt;
		};
		month_daycnt = calc_month_daycnt(year, month);
	};
	for (day < 1) {
		r.year  = year;
		r.month = month;

		reckon_months(r, -1);

		year   = r.year  as int;
		month  = r.month as int;
		day   += calc_month_daycnt(year, month);
	};

	r.year  = year;
	r.month = month;
	r.day   = day;
};

fn reckon_hours(r: *virtual, hours: i64, calc: calculus) void = {
	let hour = r.hour as int;

	hour += hours: int;

	// hour overflow
	for (hour >= 24) {
		reckon_days(r, 1, calc);
		hour -= 24;
	};
	for (hour < 0) {
		reckon_days(r, -1, calc);
		hour += 24;
	};

	r.hour = hour;
};

fn reckon_minutes(r: *virtual, mins: i64, calc: calculus) void = {
	let min = r.minute as int;

	min += mins: int;

	// minute overflow
	for (min >= 60) {
		reckon_hours(r, 1, calc);
		min -= 60;
	};
	for (min < 0) {
		reckon_hours(r, -1, calc);
		min += 60;
	};

	r.minute = min;
};

fn reckon_seconds(r: *virtual, secs: i64, calc: calculus) void = {
	let s = r.second as int;

	s += secs: int;

	// second overflow
	for (s >= 60) {
		reckon_minutes(r, 1, calc);
		s -= 60;
	};
	for (s < 0) {
		reckon_minutes(r, -1, calc);
		s += 60;
	};

	r.second = s;
};

fn reckon_nanoseconds(r: *virtual, nsecs: i64, calc: calculus) void = {
	let ns = r.nanosecond as int;

	ns += nsecs: int;

	// nanosecond overflow
	for (ns >= 1000000000) { // 1E9 nanoseconds (1 second)
		reckon_seconds(r, 1, calc);
		ns -= 1000000000;
	};
	for (ns < 0) {
		reckon_seconds(r, -1, calc);
		ns += 1000000000;
	};

	r.nanosecond = ns;
};

@test fn reckon() void = {
	const Amst = chrono::tz("Europe/Amsterdam")!;
	defer chrono::timezone_free(Amst);

	// no-op period, calculus::CEIL

	let p = period { ... };

	let a = new(chrono::UTC, 0)!;
	let r = reckon(a, 0, p);
	assert(chrono::eq(&a, &r)!, "01. incorrect result");

	let a = new(chrono::UTC, 0,  2019, 12, 27,  21,  7,  8,         0)!;
	let r = reckon(a, 0, p);
	assert(chrono::eq(&a, &r)!, "02. incorrect result");

	let a = new(Amst, 1 * time::HOUR,  2019, 12, 27,  22,  7,  8,         0)!;
	let r = reckon(a, 0, p);
	assert(chrono::eq(&a, &r)!, "03. incorrect result");

	// generic periods, calculus::CEIL

	let a = new(chrono::UTC, 0,  2019, 12, 27,  21,  7,  8,         0)!;

	let r = reckon(a, 0, period {
		years       = 1,
		months      = 1,
		days        = 1,
		hours       = 1,
		minutes     = 1,
		seconds     = 1,
		nanoseconds = 1,
		...
	});
	let b = new(chrono::UTC, 0,  2021,  1, 28,  22,  8,  9,         1)!;
	assert(chrono::eq(&b, &r)!, "04. incorrect result");

	let r = reckon(a, 0, period {
		years       = -1,
		months      = -1,
		days        = -1,
		hours       = -1,
		minutes     = -1,
		seconds     = -1,
		nanoseconds = -1,
		...
	});
	let b = new(chrono::UTC, 0,  2018, 11, 26,  20,  6,  6, 999999999)!;
	assert(chrono::eq(&b, &r)!, "05. incorrect result");

	let r = reckon(a, 0, period {
		years       = 100,
		months      = 100,
		days        = 100,
		hours       = 100,
		minutes     = 100,
		seconds     = 100,
		nanoseconds = 100,
		...
	});
	let b = new(chrono::UTC, 0,  2128,  8, 10,   2, 48, 48,       100)!;
	assert(chrono::eq(&b, &r)!, "06. incorrect result");

	let r = reckon(a, 0, period {
		years       = -100,
		months      = -100,
		days        = -100,
		hours       = -100,
		minutes     = -100,
		seconds     = -100,
		nanoseconds = -100,
		...
	});
	let b = new(chrono::UTC, 0,  1911,  5, 15,  15, 25, 27, 999999900)!;
	assert(chrono::eq(&b, &r)!, "07. incorrect result");

	let r = reckon(a, 0, period {
		weeks = 100,
		...
	});
	let b = new(chrono::UTC, 0,  2021, 11, 26,  21,  7,  8,         0)!;
	assert(chrono::eq(&b, &r)!, "08. incorrect result");

	// calculus, February 29 overflows

	let a = new(chrono::UTC, 0,  2000,  1, 31)!; // leap year
	let p = period { months = 1, ... };

	let r = reckon(a, calculus::FLOOR, p);
	let b = new(chrono::UTC, 0,  2000,  2,  1)!;
	assert(chrono::eq(&b, &r)!, "09. incorrect result");

	let r = reckon(a, calculus::CEIL, p);
	let b = new(chrono::UTC, 0,  2000,  2, 29)!;
	assert(chrono::eq(&b, &r)!, "10. incorrect result");

	let r = reckon(a, calculus::HOP, p);
	let b = new(chrono::UTC, 0,  2000,  3,  1)!;
	assert(chrono::eq(&b, &r)!, "11. incorrect result");

	let r = reckon(a, calculus::FOLD, p);
	let b = new(chrono::UTC, 0,  2000,  3,  2)!;
	assert(chrono::eq(&b, &r)!, "12. incorrect result");

	// calculus, February 28 overflows

	let a = new(chrono::UTC, 0,  2000,  1, 31)!; // leap year
	let p = period { years = 1, months = 1, ... };

	let r = reckon(a, calculus::FLOOR, p);
	let b = new(chrono::UTC, 0,  2001,  2,  1)!;
	assert(chrono::eq(&b, &r)!, "13. incorrect result");

	let r = reckon(a, calculus::CEIL, p);
	let b = new(chrono::UTC, 0,  2001,  2, 28)!;
	assert(chrono::eq(&b, &r)!, "14. incorrect result");

	let r = reckon(a, calculus::HOP, p);
	let b = new(chrono::UTC, 0,  2001,  3,  1)!;
	assert(chrono::eq(&b, &r)!, "15. incorrect result");

	let r = reckon(a, calculus::FOLD, p);
	let b = new(chrono::UTC, 0,  2001,  3,  3)!;
	assert(chrono::eq(&b, &r)!, "16. incorrect result");

	// multiple periods

	let a = new(chrono::UTC, 0,  2000, 12, 31)!;
	let ps = [
		period { years = +1, months = +1, days = +1, ... },
		period { years = -1, months = -1, days = -1, ... },
		period { years = -1, months = -1, days = -1, ... },
		period { years = +1, months = +1, days = +1, ... },
		period { hours = +1, minutes = +1, seconds = +1, ... },
		period { hours = -1, minutes = -1, seconds = -1, ... },
		period { hours = -1, minutes = -1, seconds = -1, ... },
		period { hours = +1, minutes = +1, seconds = +1, ... },
	];

	let r = reckon(a, 0, ps[..1]...);
	let b = new(chrono::UTC, 0,  2002,  2,  1)!;
	assert(chrono::eq(&b, &r)!, "17. incorrect result");

	let r = reckon(a, 0, ps[..2]...);
	let b = new(chrono::UTC, 0,  2000, 12, 31)!;
	assert(chrono::eq(&b, &r)!, "18. incorrect result");

	let r = reckon(a, 0, ps[..3]...);
	let b = new(chrono::UTC, 0,  1999, 11, 29)!;
	assert(chrono::eq(&b, &r)!, "19. incorrect result");

	let r = reckon(a, 0, ps[..4]...);
	let b = new(chrono::UTC, 0,  2000, 12, 30)!;
	assert(chrono::eq(&b, &r)!, "20. incorrect result");

	let r = reckon(a, 0, ps[..5]...);
	let b = new(chrono::UTC, 0,  2000, 12, 30,   1,  1,  1)!;
	assert(chrono::eq(&b, &r)!, "21. incorrect result");

	let r = reckon(a, 0, ps[..6]...);
	let b = new(chrono::UTC, 0,  2000, 12, 30)!;
	assert(chrono::eq(&b, &r)!, "22. incorrect result");

	let r = reckon(a, 0, ps[..7]...);
	let b = new(chrono::UTC, 0,  2000, 12, 29,  22, 58, 59)!;
	assert(chrono::eq(&b, &r)!, "23. incorrect result");

	let r = reckon(a, 0, ps[..8]...);
	let b = new(chrono::UTC, 0,  2000, 12, 30)!;
	assert(chrono::eq(&b, &r)!, "24. incorrect result");

	// multiple periods, calculus::REVSIG

	let a = new(chrono::UTC, 0,  2000, 12, 31)!;
	let ps = [
		period { years = +1, months = +1, days = +1, ... },
		period { years = -1, months = -1, days = -1, ... },
		period { years = -1, months = -1, days = -1, ... },
		period { years = +1, months = +1, days = +1, ... },
		period { hours = +1, minutes = +1, seconds = +1, ... },
		period { hours = -1, minutes = -1, seconds = -1, ... },
		period { hours = -1, minutes = -1, seconds = -1, ... },
		period { hours = +1, minutes = +1, seconds = +1, ... },
	];

	let r = reckon(a, calculus::REVSIG, ps[..1]...);
	let b = new(chrono::UTC, 0,  2002,  2,  1)!;
	assert(chrono::eq(&b, &r)!, "25. incorrect result");

	let r = reckon(a, calculus::REVSIG, ps[..2]...);
	let b = new(chrono::UTC, 0,  2000, 12, 31)!;
	assert(chrono::eq(&b, &r)!, "26. incorrect result");

	let r = reckon(a, calculus::REVSIG, ps[..3]...);
	let b = new(chrono::UTC, 0,  1999, 11, 30)!;
	assert(chrono::eq(&b, &r)!, "27. incorrect result");

	let r = reckon(a, calculus::REVSIG, ps[..4]...);
	let b = new(chrono::UTC, 0,  2001,  1,  1)!;
	assert(chrono::eq(&b, &r)!, "28. incorrect result");

	let r = reckon(a, calculus::REVSIG, ps[..5]...);
	let b = new(chrono::UTC, 0,  2001,  1,  1,   1,  1,  1)!;
	assert(chrono::eq(&b, &r)!, "29. incorrect result");

	let r = reckon(a, calculus::REVSIG, ps[..6]...);
	let b = new(chrono::UTC, 0,  2001,  1,  1)!;
	assert(chrono::eq(&b, &r)!, "30. incorrect result");

	let r = reckon(a, calculus::REVSIG, ps[..7]...);
	let b = new(chrono::UTC, 0,  2000, 12, 31,  22, 58, 59)!;
	assert(chrono::eq(&b, &r)!, "31. incorrect result");

	let r = reckon(a, calculus::REVSIG, ps[..8]...);
	let b = new(chrono::UTC, 0,  2001,  1,  1)!;
	assert(chrono::eq(&b, &r)!, "32. incorrect result");

	return;
};
diff --git a/datetime/time.ha b/datetime/time.ha
index ea5a1e55..3bd110bd 100644
--- a/datetime/time.ha
+++ b/datetime/time.ha
@@ -16,7 +16,7 @@ fn calc_hmsn(t: time::duration) (int, int, int, int) = {

// Calculates the time since the start of a day,
// given a wall clock (hour, minute, second, nanosecond).
fn calc_time_from_hmsn(
fn calc_time__hmsn(
	hour: int,
	min: int,
	sec: int,
diff --git a/datetime/timezone.ha b/datetime/timezone.ha
index a1503650..268adb95 100644
--- a/datetime/timezone.ha
+++ b/datetime/timezone.ha
@@ -6,6 +6,8 @@ use time::chrono;

// Creates an equivalent [[datetime]] with a different
// [[time::chrono::locality]].
export fn in(loc: chrono::locality, dt: datetime) datetime = {
	return from_moment(chrono::in(loc, *(&dt: *chrono::moment)));
//
// The [[discontinuity]] rules from [[time::chrono::in]] apply here.
export fn in(loc: chrono::locality, dt: datetime) (datetime | chrono::discontinuity) = {
	return from_moment(chrono::in(loc, *(&dt: *chrono::moment))?);
};
diff --git a/datetime/virtual.ha b/datetime/virtual.ha
new file mode 100644
index 00000000..d5e5b971
--- /dev/null
+++ b/datetime/virtual.ha
@@ -0,0 +1,225 @@
// License: MPL-2.0
// (c) 2021-2022 Byron Torres <b@torresjrjr.com>
use time;
use time::chrono;

// A [[virtual]] has insufficient information and cannot create a valid datetime.
export type insufficient = !void;

// A virtual datetime; a [[datetime]] wrapper interface, which represents a
// datetime of uncertain validity. Its fields need not be valid observed
// chronological values. It is meant as an intermediary container for datetime
// information to be resolved with the [[realize]] function.
//
// Unlike [[datetime]], a virtual's fields are meant to be treated as public and
// mutable. The embedded [[time::instant]] and [[time::chrono::locality]] fields
// (.sec .nsec .loc) are considered meaningless. Behaviour with the "observe"
// functions is undefined.
//
// This can be used to construct a new [[datetime]] piece-by-piece. Start with
// [[newvirtual]], then collect enough date/time information incrementally by
// direct field assignments and/or with [[parse]]. Finish with [[realize]].
//
// 	let v = datetime::newvirtual();
// 	v.vloc = time::chrono::UTC;
// 	v.zoff = 0;
// 	datetime::parse(&v, "Date: %Y-%m-%d", "Date: 2038-01-19")!;
// 	v.hour = 03;
// 	v.minute = 14;
// 	v.second = 07;
// 	v.nanosecond = 0;
// 	let dt = datetime::realize(v)!;
//
export type virtual = struct {
	datetime,
	// virtual's locality
	vloc:     (void | time::chrono::locality),
	// locality name
	locname:  (void | str),
	// zone offset
	zoff:     (void | time::duration),
	// zone abbreviation
	zabbr:    (void | str),
	// hour of 12 hour clock
	halfhour: (void | int),
	// AM/PM (false/true)
	ampm:     (void | bool),
};

// Creates a new [[virtual]]. All its fields are voided or nulled appropriately.
export fn newvirtual() virtual = virtual {
	sec         = 0,
	nsec        = 0,
	loc         = chrono::UTC,
	zone        = null,
	date        = void,
	time        = void,

	era         = void,
	year        = void,
	month       = void,
	day         = void,
	yearday     = void,
	isoweekyear = void,
	isoweek     = void,
	week        = void,
	sundayweek  = void,
	weekday     = void,

	hour        = void,
	minute      = void,
	second      = void,
	nanosecond  = void,

	vloc        = void,
	locname     = void,
	zoff        = void,
	zabbr       = void,
	halfhour    = void,
	ampm        = void,
};

// Realizes a valid [[datetime]] from a [[virtual]], or fails appropriately.
// Four values require determination. Each has various determination strategies,
// each of which use a certain set of non-void fields from the given virtual.
// The following determination strategies will be attempted in order.
//
// Field sets for determining the date:
//
// 1. date
// 2. year, month, day
// 3. year, yearday
// 4. year, week, weekday
// 5. isoweekyear, isoweek, weekday
//
// Field sets for determining the time:
//
// 1. time
// 2. hour, minute, second, nanosecond
//
// Field sets for determining the zone offset:
//
// 1. zoff
//
// Field sets for determining the [[time::chrono::locality]]:
//
// 1. vloc
// 2. locname
//         This is compared to each provided locality's 'name' field,
//         or "UTC" if none are provided. The first match is used.
// 3. (none)
//         Defaults to [[time::chrono::UTC]].
//
// If for any of these values no determination strategy could be attempted,
// [[insufficient]] is returned. If the resultant datetime is invalid,
// [[invalid]] is returned.
export fn realize(
	v: virtual,
	locs: time::chrono::locality...
) (datetime | insufficient | invalid) = {
	// determine .date
	if (v.date is i64) {
		void;
	} else if (
		v.year is int &&
		v.month is int &&
		v.day is int
	) {
		v.date = calc_date__ymd(
			v.year as int,
			v.month as int,
			v.day as int,
		)?;
	} else if (
		v.year is int &&
		v.yearday is int
	) {
		v.date = calc_date__yd(
			v.year as int,
			v.yearday as int,
		)?;
	} else if (
		v.year is int &&
		v.week is int &&
		v.weekday is int
	) {
		v.date = calc_date__ywd(
			v.year as int,
			v.week as int,
			v.weekday as int,
		)?;
	} else if (false) {
		// TODO: calendar.ha: calc_date__isoywd()
		void;
	} else {
		// cannot deduce date
		return insufficient;
	};

	// determine .time
	if (v.time is time::duration) {
		void;
	} else {
		const hour = if (v.hour is int) {
			yield v.hour as int;
		} else if (v.halfhour is int && v.ampm is bool) {
			const hr = v.halfhour as int;
			const pm = v.ampm as bool;
			yield if (pm) hr * 2 else hr;
		} else {
			return insufficient;
		};

		if (
			v.minute is int &&
			v.second is int &&
			v.nanosecond is int
		) {
			v.time = calc_time__hmsn(
				hour,
				v.minute as int,
				v.second as int,
				v.nanosecond as int,
			)?;
		} else {
			return insufficient;
		};
	};

	// determine zone offset
	if (v.zoff is time::duration) {
		void;
	} else {
		return insufficient;
	};

	// determine .loc (defaults to time::chrono::UTC)
	if (v.vloc is chrono::locality) {
		v.loc = v.vloc as chrono::locality;
	} else if (v.locname is str) {
		v.loc = chrono::UTC;
		for (let i = 0z; i < len(locs); i += 1) {
			const loc = locs[i];
			if (loc.name == v.locname as str) {
				v.loc = loc;
				break;
			};
		};
	};

	// determine .sec, .nsec
	const dt = from_moment(chrono::from_datetime(
		v.loc,
		v.zoff as time::duration,
		v.date as i64,
		v.time as time::duration,
	));

	// verify zone offset
	const z = chrono::mzone(&dt);
	if (z.zoff != v.zoff as time::duration) {
		return invalid;
	};

	return dt;
};
diff --git a/scripts/gen-stdlib b/scripts/gen-stdlib
index 66f0bafa..ba7019c3 100755
--- a/scripts/gen-stdlib
+++ b/scripts/gen-stdlib
@@ -191,20 +191,28 @@ datetime() {
		chronology.ha \
		date.ha \
		datetime.ha \
		duration.ha \
		format.ha \
		parse.ha \
		period.ha \
		reckon.ha \
		time.ha \
		timezone.ha
		timezone.ha \
		virtual.ha
	gen_ssa -plinux datetime errors fmt strings strio time time::chrono
	gen_srcs -pfreebsd datetime \
		arithmetic.ha \
		chronology.ha \
		date.ha \
		datetime.ha \
		duration.ha \
		format.ha \
		parse.ha \
		period.ha \
		reckon.ha \
		time.ha \
		timezone.ha
		timezone.ha \
		virtual.ha
	gen_ssa -pfreebsd datetime errors fmt strings strio time time::chrono
}

@@ -1322,6 +1330,7 @@ time() {

time_chrono() {
	gen_srcs -plinux time::chrono \
		arithmetic.ha \
		+linux.ha \
		chronology.ha \
		error.ha \
@@ -1332,6 +1341,7 @@ time_chrono() {
	gen_ssa -plinux time::chrono \
		bufio bytes encoding::utf8 endian errors fmt fs io os strconv strings time path
	gen_srcs -pfreebsd time::chrono \
		arithmetic.ha \
		+freebsd.ha \