diff --git a/TimeKeeping/DotNetThoughts.TimeKeeping.Tests/LocalDateTimeTests.cs b/TimeKeeping/DotNetThoughts.TimeKeeping.Tests/LocalDateTimeTests.cs new file mode 100644 index 0000000..9cc45b4 --- /dev/null +++ b/TimeKeeping/DotNetThoughts.TimeKeeping.Tests/LocalDateTimeTests.cs @@ -0,0 +1,121 @@ +namespace DotNetThoughts.TimeKeeping.Tests; +public class LocalDateTimeTests +{ + + [Fact] + public void TestLocalDateTimeOffset() + { + var t = new DateTime(2024,06,06, 0, 0, 0, DateTimeKind.Unspecified); + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var ldt = new LocalDateTime(t, tz); + var expectedDateTimeOffset = DateTimeOffset.Parse("2024-06-06T00:00:00+02:00"); + ldt.ToDateTimeOffsetLocal().Should().Be(expectedDateTimeOffset); + } + + [Fact] + public void TestDateConstructor() + { + var t = new DateTime(2024, 06, 06, 0, 0, 0, DateTimeKind.Unspecified); + var d = new DateOnly(2024, 06, 06); + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var ldt = new LocalDateTime(t, tz); + var ldt2 = new LocalDateTime(d, tz); + + ldt.DateTime.Should().Be(ldt2.DateTime); + } + + [Fact] + public void TestUtcDateTimeOffset() + { + var t = new DateTime(2024, 06, 06, 0, 0, 0, DateTimeKind.Unspecified); + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var ldt = new LocalDateTime(t, tz); + var expectedUtcDateTimeOffset = DateTimeOffset.Parse("2024-06-05T22:00:00+00:00"); + ldt.ToDateTimeOffsetUtc().Should().Be(expectedUtcDateTimeOffset); + } + + [Fact] + public void InTheMiddleOfSummerTimeShift() + { + var t = new DateTime(2024, 03, 31, 2, 30, 0, DateTimeKind.Unspecified); + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var ldt = () => new LocalDateTime(t, tz); + ldt.Should().Throw(); + } + + [Fact] + public void InTheMiddleOfWinterTimeShift() + { + var t = new DateTime(2024, 10, 27, 2, 30, 0, DateTimeKind.Unspecified); + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var ldt = () => new LocalDateTime(t, tz); + ldt.Should().Throw(); + } + + [Fact] + public void TestLocalMidnight() + { + var t = new DateTime(2024, 06, 06, 13, 2, 1, DateTimeKind.Unspecified); + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var ldt = new LocalDateTime(t, tz).ToMidnight(); + var expectedDateTimeOffset = DateTimeOffset.Parse("2024-06-06T00:00:00+02:00"); + ldt.ToDateTimeOffsetLocal().Should().Be(expectedDateTimeOffset); + } + + [Fact] + public void LocalTimeKindaWorks() + { + var nowInContextOfTest = DateTimeOffset.Now; + + var t = new DateTime(nowInContextOfTest.Ticks, DateTimeKind.Local); + var tz = TimeZoneInfo.Local; + var ldt = new LocalDateTime(t, tz); + ldt.ToDateTimeOffsetLocal().Should().Be(nowInContextOfTest); + } + + [Fact] + public void LocalTimeKindaWorksWithUtc() + { + var nowInContextOfTest = DateTimeOffset.Now; + var utcNow = nowInContextOfTest.ToUniversalTime(); + var t = new DateTime(nowInContextOfTest.Ticks, DateTimeKind.Local); + var tz = TimeZoneInfo.Local; + var ldt = new LocalDateTime(t, tz); + ldt.ToDateTimeOffsetUtc().Should().Be(utcNow); + } + + [Theory] + [InlineData("2022-01-01T16:00:00", 1, "2022-01-01T00:00:00", "2022-01-01T00:00:00+01:00", "2021-12-31T23:00:00+00:00")] + [InlineData("2022-01-01T00:00:00", 1, "2021-12-31T00:00:00", "2021-12-31T00:00:00+01:00", "2021-12-30T23:00:00+00:00")] + [InlineData("2022-01-01T16:00:00", 2, "2021-12-31T00:00:00", "2021-12-31T00:00:00+01:00", "2021-12-30T23:00:00+00:00")] + [InlineData("2024-04-05T16:00:00", 7, "2024-03-30T00:00:00", "2024-03-30T00:00:00+01:00", "2024-03-29T23:00:00+00:00")] + [InlineData("2024-10-28T16:00:00", 3, "2024-10-26T00:00:00", "2024-10-26T00:00:00+02:00", "2024-10-25T22:00:00+00:00")] + public void PastMidnightsTest(string date, int midnights, string expected, string expectedLocalOffset, string expectedUtcOffset) + { + var t = DateTime.Parse(date); + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var ldt = new LocalDateTime(t, tz); + var past = ldt.PastMidnights(midnights); + past.Should().Be(new LocalDateTime(DateTime.Parse(expected), tz)); + past.ToDateTimeOffsetLocal().Should().Be(DateTimeOffset.Parse(expectedLocalOffset)); + past.ToDateTimeOffsetUtc().Should().Be(DateTimeOffset.Parse(expectedUtcOffset)); + } + + [Theory] + [InlineData("2022-01-01T16:00:00", 1, "2022-01-02T00:00:00", "2022-01-02T00:00:00+01:00", "2022-01-01T23:00:00+00:00")] + [InlineData("2022-01-01T00:00:00", 1, "2022-01-02T00:00:00", "2022-01-02T00:00:00+01:00", "2022-01-01T23:00:00+00:00")] + [InlineData("2022-01-01T16:00:00", 2, "2022-01-03T00:00:00", "2022-01-03T00:00:00+01:00", "2022-01-02T23:00:00+00:00")] + [InlineData("2024-03-30T16:00:00", 6, "2024-04-05T00:00:00", "2024-04-05T00:00:00+02:00", "2024-04-04T22:00:00+00:00")] + [InlineData("2024-10-26T16:00:00", 2, "2024-10-28T00:00:00", "2024-10-28T00:00:00+01:00", "2024-10-27T23:00:00+00:00")] + public void FutureMidnightsTest(string date, int midnights, string expected, string expectedLocalOffset, string expectedUtcOffset) + { + var t = DateTime.Parse(date); + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var ldt = new LocalDateTime(t, tz); + var future = ldt.FutureMidnights(midnights); + future.Should().Be(new LocalDateTime(DateTime.Parse(expected), tz)); + future.ToDateTimeOffsetLocal().Should().Be(DateTimeOffset.Parse(expectedLocalOffset)); + future.ToDateTimeOffsetUtc().Should().Be(DateTimeOffset.Parse(expectedUtcOffset)); + + } +} diff --git a/TimeKeeping/DotNetThoughts.TimeKeeping/LocalDateTime.cs b/TimeKeeping/DotNetThoughts.TimeKeeping/LocalDateTime.cs new file mode 100644 index 0000000..d46b9fc --- /dev/null +++ b/TimeKeeping/DotNetThoughts.TimeKeeping/LocalDateTime.cs @@ -0,0 +1,96 @@ +namespace DotNetThoughts.TimeKeeping; + +/// +/// Unambigious representation of a local datetime +/// +public readonly record struct LocalDateTime +{ + public LocalDateTime(DateOnly date, TimeZoneInfo timeZoneInfo) + : this(date.ToDateTime(TimeOnly.MinValue), timeZoneInfo) { } + + public LocalDateTime(DateTime dateTime, TimeZoneInfo timeZoneInfo) + { + if (!((dateTime.Kind == DateTimeKind.Local && timeZoneInfo == TimeZoneInfo.Local) + || (dateTime.Kind == DateTimeKind.Utc && timeZoneInfo == TimeZoneInfo.Utc) + || (dateTime.Kind == DateTimeKind.Unspecified && timeZoneInfo != TimeZoneInfo.Local))) + { + throw new ArgumentException("Invalid combinations of datetime kinds and timezoneinfo"); + } + + if (timeZoneInfo.IsInvalidTime(dateTime)) + { + throw new ArgumentException("dateTime is invalid for the timezone"); + } + + if (timeZoneInfo.IsAmbiguousTime(dateTime)) + { + throw new ArgumentException("dateTime is ambiguous for the timezone"); + } + + DateTime = dateTime; + TimeZoneInfo = timeZoneInfo; + } + + public DateTime DateTime { get; } + public TimeZoneInfo TimeZoneInfo { get; } + + public LocalDateTime ToMidnight() + { + return new LocalDateTime(DateTime.Date, TimeZoneInfo); + } + + public LocalDateTime ToBeginningOfYear() + { + return new LocalDateTime(new DateTime(DateTime.Year, 1, 1), TimeZoneInfo); + } + + public LocalDateTime ToEndOfYear() + { + return new LocalDateTime(new DateTime(DateTime.Year, 12, 31, 23, 59, 59, 999, 999), TimeZoneInfo); + } + + public DateTimeOffset ToDateTimeOffsetUtc() + { + var shouldHaveHadKindUtc = TimeZoneInfo.ConvertTimeToUtc(DateTime, TimeZoneInfo); + return shouldHaveHadKindUtc; + } + + public DateTimeOffset ToDateTimeOffsetLocal() + { + return new DateTimeOffset(DateTime, TimeZoneInfo.GetUtcOffset(DateTime)); + } + + /// + /// Imagine past occurrences of midnight as a 1-indexed array where the first occurence is the last midnight experienced. + /// + public LocalDateTime PastMidnights(int count) + { + if (count < 1) + { + throw new ArgumentException("Count must be greater than 0."); + } + + if (DateTime.TimeOfDay == TimeSpan.Zero) + { + return new LocalDateTime(DateTime.AddDays(-count), TimeZoneInfo).ToMidnight(); + } + else + { + return new LocalDateTime(DateTime.AddDays(-(count - 1)), TimeZoneInfo).ToMidnight(); + } + } + + /// + /// Imagine future occurrences of midnight as a 1-indexed array where the first occurence is the next midnight experienced. + /// + public LocalDateTime FutureMidnights(int count) + { + if (count < 1) + { + throw new ArgumentException("Count must be greater than 0."); + } + + return new LocalDateTime(DateTime.AddDays(count), TimeZoneInfo).ToMidnight(); + } + +}