C
C#2y ago
Kiel

❔ Converting my project to NodaTime

Hi there, after some conversation in my previous partially related thread #Accounting for Daylight Savings Time skips at runtime, I'm planning on converting existing functionality in my project (a Discord bot) to NodaTime's types instead of the BCL types, to avoid issues that might occur from constantly converting to and from different time-related types. There are a lot of classes in NodaTime that can be used to represent a "moment in time" so to speak, so I'd like to know which ones I should use for different situations. Disclaimer: I am using EFCore with PostgreSQL, and will now be using Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime for storing appropriate types. - Most of my database entities utilize DateTimeOffset for their creation date and time - always stored in UTC as I believe EFCore cannot store DateTimeOffsets with a non-UTC offset. I'm unsure whether to use OffsetDateTime, ZonedDateTime, or Instant. - Many classes implement a timer interface, and utilize a DateTimeOffset for when the timer "expires". See the linked thread for issues related to Daylight Savings Time - I believe I will need to store a LocalTime or Instant and convert it to the user's timezone at runtime (see next point) - Users can set their timezone via a command - allowing using https://github.com/robbell/nChronic.Core for natural date parsing in their local time, instead of using UTC for everything. I will likely store their timezone as TimeZoneInfo instead of TimeSpan which I previously used. Any suggestions for how to handle these types of situations? I'm trying to make sure I can make this transition as clean and bug-free as possible, since I have a lot of logic implemented that relies on the BCL time/date types.
35 Replies
jcotton42
jcotton422y ago
these links should answer most of what you're asking https://nodatime.org/3.1.x/userguide/type-choices https://www.npgsql.org/doc/types/nodatime.html?tabs=datasource I can say for the first one, you want an Instant a creation time is an unambiguous point on the global timeline, which is what Instant is for also, you rarely want OffsetDateTime it's like BCL's DateTimeOffset, it doesn't store a timezone, just a UTC offset
Kiel
KielOP2y ago
Another concern of mine is related to what the best NodaTime equivalent is to DateTimeOffset.UtcNow. There doesn't seem to be an Instant.Now, and https://stackoverflow.com/questions/14531627/instant-now-for-nodatime's answers are...not filling me with confidence. Especially when it comes to storing creation times in a "universal" state, what happens if I construct an Instant in my testing environment, where my computer's time is in US Central Time, but my production environment is on a device or container in UTC? "Now" would be way off.
jcotton42
jcotton422y ago
Instant doesn't have a timezone it's always in UTC effectively as for why there's no Instant.Now, that's because they come from clocks
Kiel
KielOP2y ago
so per that SO thread, SystemClock.Instance.GetCurrentInstant() would return "now" as an instant in UTC and not my system's local timezone/offset?
jcotton42
jcotton422y ago
NodaTime provides two versions of IClock SystemClock (accessible through SystemClock.Instance) always uses real world time and FakeClock is for testing, and lets you control exactly what it returns
Kiel
KielOP2y ago
Guess since I have testing commands I could TIAS
jcotton42
jcotton422y ago
yes it's an instant on the global timeline it doesn't interact with timezones at all for that you need LocalDate/LocalTime/LocalDateTime (which has no timezone info, and is assumed to be local) or the various Zoned types
Kiel
KielOP2y ago
gotcha. I assumed Instant was sorta like doing DateTimeOffset.Now and was always going to get messed up with timezones
jcotton42
jcotton422y ago
no
Kiel
KielOP2y ago
naming was a little confusing, it's an "instant" in time obviously, but to me this instant is time in 1:55 PM, and to my friend in the UK it's several hours later, so it wasn't straightforward to me, but I get it now
jcotton42
jcotton422y ago
it explicitly does not interact with timezones at all
Kiel
KielOP2y ago
@jcotton42 this might be a complicated or very simple question, I'm not sure. I'm converting a TimeSpan parser to utilize NT's Period class, as it has proper support for adding years/months/weeks/etc. My current logic is like so (abbreviated for clarity):
// public static bool TryParse(string value, DateTimeZone timeZone, [NotNullWhen(true)] out Period? period)

var now = SystemClock.Instance.GetCurrentInstant().InZone(timeZone).LocalDateTime;
var end = now;

foreach (var match in PeriodRegex.Matches(value).Cast<Match>())
{
// amount is 'int'
// character is ywdhms, isMonth indicates next character is 'o' indicating months not minutes

end = character switch
{
'y' => end.PlusYears(amount),
'm' when isMonth => end.PlusMonths(amount),
'w' => end.PlusWeeks(amount),
'd' => end.PlusDays(amount),
'h' => end.PlusHours(amount),
'm' => end.PlusMinutes(amount),
's' => end.PlusSeconds(amount),
_ => throw new ArgumentOutOfRangeException()
};
}

var differencePeriod = Period.Between(now, end);
// public static bool TryParse(string value, DateTimeZone timeZone, [NotNullWhen(true)] out Period? period)

var now = SystemClock.Instance.GetCurrentInstant().InZone(timeZone).LocalDateTime;
var end = now;

foreach (var match in PeriodRegex.Matches(value).Cast<Match>())
{
// amount is 'int'
// character is ywdhms, isMonth indicates next character is 'o' indicating months not minutes

end = character switch
{
'y' => end.PlusYears(amount),
'm' when isMonth => end.PlusMonths(amount),
'w' => end.PlusWeeks(amount),
'd' => end.PlusDays(amount),
'h' => end.PlusHours(amount),
'm' => end.PlusMinutes(amount),
's' => end.PlusSeconds(amount),
_ => throw new ArgumentOutOfRangeException()
};
}

var differencePeriod = Period.Between(now, end);
What do I do with differencePeriod to determine that it is non-zero? Period doesn't seem to implement equality operators. Do I just compare the result of differencePeriod.ToDuration()?
jcotton42
jcotton422y ago
it does though? but note this
Period equality is implemented by comparing each property's values individually, without any normalization. (For example, a period of "24 hours" is not considered equal to a period of "1 day".) The static NormalizingEqualityComparer comparer provides an equality comparer which performs normalization before comparisons.
Kiel
KielOP2y ago
er...by equality I meant greater than, my bad
jcotton42
jcotton422y ago
ah
Kiel
KielOP2y ago
what I did was period = differencePeriod > Period.Zero ? differencePeriod : null;
jcotton42
jcotton422y ago
then yeah, I'd convert to Duration first
Kiel
KielOP2y ago
but it doesn't have that operator implemented, blobthumbsup
Kiel
KielOP2y ago
I think I'll use the duration method, as long as it works out. I think the comparer is overkill since it looks like it's intended for comparing "1 month" to "30 days" to see if they are equivalent. I'm just comparing to a zero value 😛 Now I need to find a way to add a Period to either an Instant or a ZonedDateTime... I also had a (command) parser for DateTimeOffset, which I am now repurposing for either Instant or ZonedDateTime, I think the latter makes more sense given the context (a user specifying a place in time like tomorrow at noon, which needs to be timezone aware)
jcotton42
jcotton422y ago
the raw input would be LocalDateTime, I think you would then combine that with the timezone setting to make a ZonedDateTime or Instant
Kiel
KielOP2y ago
That makes sense, that's actually what I was starting to do, but I'm unsure how to "combine" it properly.
// if we parse a Period instead of "tomorrow at noon", return the user's "now" (with timezone) + the period
if (PeriodTypeParser.TryParse(input, globalUser.GetTimeZone(), out var period))
{
var now = SystemClock.Instance.GetCurrentInstant().InZone(globalUser.GetTimeZone()).LocalDateTime;
return Success((now + period).InZoneLeniently(globalUser.GetTimeZone()));
}
// if we parse a Period instead of "tomorrow at noon", return the user's "now" (with timezone) + the period
if (PeriodTypeParser.TryParse(input, globalUser.GetTimeZone(), out var period))
{
var now = SystemClock.Instance.GetCurrentInstant().InZone(globalUser.GetTimeZone()).LocalDateTime;
return Success((now + period).InZoneLeniently(globalUser.GetTimeZone()));
}
Wouldn't this result in an issue somewhere? I'm getting the LocalDateTime from a ZonedDateTime, adding a Period, and then...converting it back to a ZonedDateTime? I assumed because LocalDateTime isn't timezone aware, I don't know what happens when I apply InZoneLeniently()
jcotton42
jcotton422y ago
you can add durations to a zoned datetime it's to handle DST and similar shenanigans https://nodatime.org/3.0.x/api/NodaTime.LocalDateTime.html#NodaTime_LocalDateTime_InZoneLeniently_NodaTime_DateTimeZone_
Kiel
KielOP2y ago
so this would suffice?
var now = SystemClock.Instance.GetCurrentInstant().InZone(globalUser.GetTimeZone());
return Success(now + period.ToDuration());
var now = SystemClock.Instance.GetCurrentInstant().InZone(globalUser.GetTimeZone());
return Success(now + period.ToDuration());
The good and bad of NodaTime is that there's so many ways to express similar human concepts of time, but I'm always unsure which is more applicable sometimes. I wasn't sure if converting to a Duration would make it lose track of things like differing amounts of days in a month, leap years, etc... yeah, that was what I learned in the previous DST thread you helped me with 😅 - that much makes sense
jcotton42
jcotton422y ago
a Duration is a fixed length of time so a number of seconds I guess ZonedDateTime is aware of both the time zone and the calendar system in use for it, so it will handle all that for you
Kiel
KielOP2y ago
Yeah, more similar to TimeSpan. Just wasn't sure since converting 1 month to a number of seconds would vary depending on what month it is thisisfine know for sure once I'm done with my conversion to NT's types what's working and what's not, all my paranoia is purely speculative since I haven't gotten around to actually testing yet
jcotton42
jcotton422y ago
actually, that might not work? https://nodatime.org/3.0.x/api/NodaTime.Period.html#NodaTime_Period_ToDuration ugh, months are annoying you're probably gonna have to sit down and give the docs are thorough read
Kiel
KielOP2y ago
Periods operate on calendar-related types such as LocalDateTime whereas Duration operates on instants on the time line. (Note that although ZonedDateTime includes both concepts, it only supports duration-based arithmetic.)
uuooooooohhhh i'm gonna do some more digging
Kiel
KielOP2y ago
Yeah, I can do it with LocalDateTime it seems. My only hangup is making sure the result is in the correct timezone after I'm done doing the math. Hence my earlier message:
Wouldn't this result in an issue somewhere? I'm getting the LocalDateTime from a ZonedDateTime, adding a Period, and then...converting it back to a ZonedDateTime? I assumed because LocalDateTime isn't timezone aware, I don't know what happens when I apply InZoneLeniently()
TIAS, as always
jcotton42
jcotton422y ago
I'm developing a headache, and I'm not even the one writing this code harold
Kiel
KielOP2y ago
The funny part is, all of this headache-inducing code could be avoided if Discord would just add a damn time/date picker for slash commands then bots could just be sent a UTC-relative timestamp or duration or whatever and we can just all be happy and hold hands and dance in circles
jcotton42
jcotton422y ago
that would require Discord to do work like, there's still no multi-line input for slash commands no, modals don't count
Kiel
KielOP2y ago
or dropdowns in modals even though they WERE implemented but undocumented and then removed, or radio buttons in modals, or ANYTHING in modals
Accord
Accord2y ago
Was this issue resolved? If so, run /close - otherwise I will mark this as stale and this post will be archived until there is new activity.
Want results from more Discord servers?
Add your server