diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74d7323..e5d5ba7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 4.0.0 - [Unreleased]
+### Added
+- X10D: Added extension methods for `DateOnly`, for parity with `DateTime` and `DateTimeOffset`.
+
### Changed
- X10D: `DateTime.Age(DateTime)` and `DateTimeOffset.Age(DateTimeOffset)` parameter renamed from `asOf` to `referenceDate`.
diff --git a/X10D.Tests/src/Time/DateOnlyTests.cs b/X10D.Tests/src/Time/DateOnlyTests.cs
new file mode 100644
index 0000000..d58b9dc
--- /dev/null
+++ b/X10D.Tests/src/Time/DateOnlyTests.cs
@@ -0,0 +1,230 @@
+#if NET6_0_OR_GREATER
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using X10D.Time;
+
+namespace X10D.Tests.Time;
+
+[TestClass]
+public class DateOnlyTests
+{
+ [TestMethod]
+ public void Age_ShouldBe17_Given31December1991Birthday_And30December2017Date()
+ {
+ var reference = new DateOnly(2017, 12, 30);
+ var birthday = new DateOnly(1999, 12, 31);
+
+ int age = birthday.Age(reference);
+
+ Assert.AreEqual(17, age);
+ }
+
+ [TestMethod]
+ public void Age_ShouldBe18_Given31December1991Birthday_And1January2018Date()
+ {
+ var reference = new DateOnly(2018, 1, 1);
+ var birthday = new DateOnly(1999, 12, 31);
+
+ int age = birthday.Age(reference);
+
+ Assert.AreEqual(18, age);
+ }
+
+ [TestMethod]
+ public void Age_ShouldBe18_Given31December1991Birthday_And31December2017Date()
+ {
+ var reference = new DateOnly(2017, 12, 31);
+ var birthday = new DateOnly(1999, 12, 31);
+
+ int age = birthday.Age(reference);
+
+ Assert.AreEqual(18, age);
+ }
+
+ [TestMethod]
+ public void Deconstruct_ShouldDeconstructToTuple_GivenDateOnly()
+ {
+ var date = new DateOnly(2017, 12, 31);
+
+ (int year, int month, int day) = date;
+
+ Assert.AreEqual(2017, year);
+ Assert.AreEqual(12, month);
+ Assert.AreEqual(31, day);
+ }
+
+ [TestMethod]
+ public void First_ShouldBeSaturday_Given1Jan2000()
+ {
+ var date = new DateOnly(2000, 1, 1);
+
+ Assert.AreEqual(new DateOnly(2000, 1, 1), date.First(DayOfWeek.Saturday));
+ Assert.AreEqual(new DateOnly(2000, 1, 2), date.First(DayOfWeek.Sunday));
+ Assert.AreEqual(new DateOnly(2000, 1, 3), date.First(DayOfWeek.Monday));
+ Assert.AreEqual(new DateOnly(2000, 1, 4), date.First(DayOfWeek.Tuesday));
+ Assert.AreEqual(new DateOnly(2000, 1, 5), date.First(DayOfWeek.Wednesday));
+ Assert.AreEqual(new DateOnly(2000, 1, 6), date.First(DayOfWeek.Thursday));
+ Assert.AreEqual(new DateOnly(2000, 1, 7), date.First(DayOfWeek.Friday));
+ }
+
+ [TestMethod]
+ public void FirstDayOfMonth_ShouldBe1st_GivenToday()
+ {
+ DateOnly today = DateOnly.FromDateTime(DateTime.Now.Date);
+
+ Assert.AreEqual(new DateOnly(today.Year, today.Month, 1), today.FirstDayOfMonth());
+ }
+
+ [TestMethod]
+ public void GetIso8601WeekOfYear_ShouldReturn1_Given4January1970()
+ {
+ var date = new DateOnly(1970, 1, 4);
+ int iso8601WeekOfYear = date.GetIso8601WeekOfYear();
+
+ Assert.AreEqual(1, iso8601WeekOfYear);
+ }
+
+ [TestMethod]
+ public void GetIso8601WeekOfYear_ShouldReturn1_Given31December1969()
+ {
+ var date = new DateOnly(1969, 12, 31);
+ int iso8601WeekOfYear = date.GetIso8601WeekOfYear();
+
+ Assert.AreEqual(1, iso8601WeekOfYear);
+ }
+
+ [TestMethod]
+ public void GetIso8601WeekOfYear_ShouldReturn53_Given31December1970()
+ {
+ var date = new DateOnly(1970, 12, 31);
+ int iso8601WeekOfYear = date.GetIso8601WeekOfYear();
+
+ Assert.AreEqual(53, iso8601WeekOfYear);
+ }
+
+ [TestMethod]
+ public void IsLeapYear_ShouldBeFalse_Given1999()
+ {
+ var date = new DateOnly(1999, 1, 1);
+ Assert.IsFalse(date.IsLeapYear());
+ }
+
+ [TestMethod]
+ public void IsLeapYear_ShouldBeTrue_Given2000()
+ {
+ var date = new DateOnly(2000, 1, 1);
+ Assert.IsTrue(date.IsLeapYear());
+ }
+
+ [TestMethod]
+ public void IsLeapYear_ShouldBeFalse_Given2001()
+ {
+ var date = new DateOnly(2001, 1, 1);
+ Assert.IsFalse(date.IsLeapYear());
+ }
+
+ [TestMethod]
+ public void IsLeapYear_ShouldBeFalse_Given2100()
+ {
+ var date = new DateOnly(2100, 1, 1);
+ Assert.IsFalse(date.IsLeapYear());
+ }
+
+ [TestMethod]
+ public void LastSaturday_ShouldBe29th_Given1Jan2000()
+ {
+ var date = new DateOnly(2000, 1, 1);
+
+ Assert.AreEqual(new DateOnly(2000, 1, 29), date.Last(DayOfWeek.Saturday));
+ Assert.AreEqual(new DateOnly(2000, 1, 30), date.Last(DayOfWeek.Sunday));
+ Assert.AreEqual(new DateOnly(2000, 1, 31), date.Last(DayOfWeek.Monday));
+ Assert.AreEqual(new DateOnly(2000, 1, 25), date.Last(DayOfWeek.Tuesday));
+ Assert.AreEqual(new DateOnly(2000, 1, 26), date.Last(DayOfWeek.Wednesday));
+ Assert.AreEqual(new DateOnly(2000, 1, 27), date.Last(DayOfWeek.Thursday));
+ Assert.AreEqual(new DateOnly(2000, 1, 28), date.Last(DayOfWeek.Friday));
+ }
+
+ [TestMethod]
+ public void LastDayOfMonth_ShouldBe28th_GivenFebruary1999()
+ {
+ var february = new DateOnly(1999, 2, 1);
+
+ Assert.AreEqual(new DateOnly(february.Year, february.Month, 28), february.LastDayOfMonth());
+ }
+
+ [TestMethod]
+ public void LastDayOfMonth_ShouldBe29th_GivenFebruary2000()
+ {
+ var february = new DateOnly(2000, 2, 1);
+
+ Assert.AreEqual(new DateOnly(february.Year, february.Month, 29), february.LastDayOfMonth());
+ }
+
+ [TestMethod]
+ public void LastDayOfMonth_ShouldBe28th_GivenFebruary2001()
+ {
+ var february = new DateOnly(2001, 2, 1);
+
+ Assert.AreEqual(new DateOnly(february.Year, february.Month, 28), february.LastDayOfMonth());
+ }
+
+ [TestMethod]
+ public void LastDayOfMonth_ShouldBe30th_GivenAprilJuneSeptemberNovember()
+ {
+ var april = new DateOnly(2000, 4, 1);
+ var june = new DateOnly(2000, 6, 1);
+ var september = new DateOnly(2000, 9, 1);
+ var november = new DateOnly(2000, 11, 1);
+
+ Assert.AreEqual(new DateOnly(april.Year, april.Month, 30), april.LastDayOfMonth());
+ Assert.AreEqual(new DateOnly(june.Year, june.Month, 30), june.LastDayOfMonth());
+ Assert.AreEqual(new DateOnly(september.Year, september.Month, 30), september.LastDayOfMonth());
+ Assert.AreEqual(new DateOnly(november.Year, november.Month, 30), november.LastDayOfMonth());
+ }
+
+ [TestMethod]
+ public void LastDayOfMonth_ShouldBe31st_GivenJanuaryMarchMayJulyAugustOctoberDecember()
+ {
+ var january = new DateOnly(2000, 1, 1);
+ var march = new DateOnly(2000, 3, 1);
+ var may = new DateOnly(2000, 5, 1);
+ var july = new DateOnly(2000, 7, 1);
+ var august = new DateOnly(2000, 8, 1);
+ var october = new DateOnly(2000, 10, 1);
+ var december = new DateOnly(2000, 12, 1);
+
+ Assert.AreEqual(new DateOnly(january.Year, january.Month, 31), january.LastDayOfMonth());
+ Assert.AreEqual(new DateOnly(march.Year, march.Month, 31), march.LastDayOfMonth());
+ Assert.AreEqual(new DateOnly(may.Year, may.Month, 31), may.LastDayOfMonth());
+ Assert.AreEqual(new DateOnly(july.Year, july.Month, 31), july.LastDayOfMonth());
+ Assert.AreEqual(new DateOnly(august.Year, august.Month, 31), august.LastDayOfMonth());
+ Assert.AreEqual(new DateOnly(october.Year, october.Month, 31), october.LastDayOfMonth());
+ Assert.AreEqual(new DateOnly(december.Year, december.Month, 31), december.LastDayOfMonth());
+ }
+
+ [TestMethod]
+ public void NextSaturday_ShouldBe8th_Given1Jan2000()
+ {
+ var date = new DateOnly(2000, 1, 1);
+
+ Assert.AreEqual(new DateOnly(2000, 1, 8), date.Next(DayOfWeek.Saturday));
+ }
+
+ [TestMethod]
+ public void ToUnixTimeMilliseconds_ShouldBe946684800000_Given1Jan2000()
+ {
+ var date = new DateOnly(2000, 1, 1);
+ var time = new TimeOnly(0, 0, 0);
+
+ Assert.AreEqual(946684800000, date.ToUnixTimeMilliseconds(time));
+ }
+
+ [TestMethod]
+ public void ToUnixTimeSeconds_ShouldBe946684800_Given1Jan2000()
+ {
+ var date = new DateOnly(2000, 1, 1);
+ var time = new TimeOnly(0, 0, 0);
+
+ Assert.AreEqual(946684800, date.ToUnixTimeSeconds(time));
+ }
+}
+#endif
diff --git a/X10D/src/Time/DateOnlyExtensions.cs b/X10D/src/Time/DateOnlyExtensions.cs
new file mode 100644
index 0000000..032af08
--- /dev/null
+++ b/X10D/src/Time/DateOnlyExtensions.cs
@@ -0,0 +1,208 @@
+#if NET6_0_OR_GREATER
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
+using System.Runtime.CompilerServices;
+
+namespace X10D.Time;
+
+///
+/// Time-related extension methods for .
+///
+public static class DateOnlyExtensions
+{
+ ///
+ /// Returns the rounded-down integer number of years since a given date as of today.
+ ///
+ /// The date from which to calculate.
+ /// The rounded-down integer number of years since as of today.
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ [ExcludeFromCodeCoverage]
+ public static int Age(this DateOnly value)
+ {
+ return value.Age(DateOnly.FromDateTime(DateTime.Today));
+ }
+
+ ///
+ /// Returns the rounded-down integer number of years since a given date as of another specified date.
+ ///
+ /// The date from which to calculate.
+ /// The date to use as the calculation reference.
+ ///
+ /// The rounded-down integer number of years since as of the date specified by
+ /// .
+ ///
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ public static int Age(this DateOnly value, DateOnly referenceDate)
+ {
+ return value.ToDateTime(default).Age(referenceDate.ToDateTime(default));
+ }
+
+ ///
+ /// Deconstructs the current into its year, month, and day.
+ ///
+ /// The date to deconstruct.
+ /// When this method returns, contains the year.
+ /// When this method returns, contains the month.
+ /// When this method returns, contains the day.
+ public static void Deconstruct(this DateOnly value, out int year, out int month, out int day)
+ {
+ year = value.Year;
+ month = value.Month;
+ day = value.Day;
+ }
+
+ ///
+ /// Gets a date representing the first occurence of a specified day of the week in the current month.
+ ///
+ /// The current date.
+ /// The day of the week.
+ /// A representing the first occurence of .
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ public static DateOnly First(this DateOnly value, DayOfWeek dayOfWeek)
+ {
+ DateOnly first = value.FirstDayOfMonth();
+
+ if (first.DayOfWeek != dayOfWeek)
+ {
+ first = first.Next(dayOfWeek);
+ }
+
+ return first;
+ }
+
+ ///
+ /// Gets a date representing the first day of the current month.
+ ///
+ /// The current date.
+ /// A representing the first day of the current month.
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ public static DateOnly FirstDayOfMonth(this DateOnly value)
+ {
+ return value.AddDays(1 - value.Day);
+ }
+
+ ///
+ /// Gets the ISO-8601 week number of the year for the current date.
+ ///
+ /// The date whose week number to return.
+ /// The ISO-8601 week number of the year.
+ /// Shawn Steele, Microsoft
+ ///
+ /// This implementation is directly inspired from a
+ ///
+ /// blog post
+ /// .
+ /// about this subject.
+ ///
+ [Pure]
+ public static int GetIso8601WeekOfYear(this DateOnly value)
+ {
+ return value.ToDateTime(default).GetIso8601WeekOfYear();
+ }
+
+ ///
+ /// Returns a value indicating whether the year represented by the current is a leap year.
+ ///
+ /// The date whose year to check.
+ ///
+ /// if the year represented by is a leap year; otherwise,
+ /// .
+ ///
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ public static bool IsLeapYear(this DateOnly value)
+ {
+ return DateTime.IsLeapYear(value.Year);
+ }
+
+ ///
+ /// Gets a date representing the final occurence of a specified day of the week in the current month.
+ ///
+ /// The current date.
+ /// The day of the week.
+ /// A representing the final occurence of .
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ public static DateOnly Last(this DateOnly value, DayOfWeek dayOfWeek)
+ {
+ DateOnly last = value.LastDayOfMonth();
+ var lastDayOfWeek = last.DayOfWeek;
+
+ int diff = dayOfWeek - lastDayOfWeek;
+ int offset = diff > 0 ? diff - 7 : diff;
+
+ return last.AddDays(offset);
+ }
+
+ ///
+ /// Gets a date representing the last day of the current month.
+ ///
+ /// The current date.
+ /// A representing the last day of the current month.
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ public static DateOnly LastDayOfMonth(this DateOnly value)
+ {
+ int daysInMonth = DateTime.DaysInMonth(value.Year, value.Month);
+ return new DateOnly(value.Year, value.Month, daysInMonth);
+ }
+
+ ///
+ /// Gets a date representing the next occurence of a specified day of the week in the current month.
+ ///
+ /// The current date.
+ /// The day of the week.
+ /// A representing the next occurence of .
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ public static DateOnly Next(this DateOnly value, DayOfWeek dayOfWeek)
+ {
+ int offsetDays = dayOfWeek - value.DayOfWeek;
+
+ if (offsetDays <= 0)
+ {
+ offsetDays += 7;
+ }
+
+ return value.AddDays(offsetDays);
+ }
+
+ ///
+ /// Returns the number of milliseconds that have elapsed since 1970-01-01T00:00:00.000Z.
+ ///
+ /// The current date.
+ /// A reference time to use with the current date.
+ /// The number of milliseconds that have elapsed since 1970-01-01T00:00:00.000Z.
+ [Pure]
+#if NETSTANDARD2_1
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+#else
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+#endif
+ public static long ToUnixTimeMilliseconds(this DateOnly value, TimeOnly time)
+ {
+ return value.ToDateTime(time).ToUnixTimeMilliseconds();
+ }
+
+ ///
+ /// Returns the number of seconds that have elapsed since 1970-01-01T00:00:00.000Z.
+ ///
+ /// The current date.
+ /// A reference time to use with the current date.
+ /// The number of seconds that have elapsed since 1970-01-01T00:00:00.000Z.
+ [Pure]
+#if NETSTANDARD2_1
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+#else
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+#endif
+ public static long ToUnixTimeSeconds(this DateOnly value, TimeOnly time)
+ {
+ return value.ToDateTime(time).ToUnixTimeSeconds();
+ }
+}
+#endif