tdammers / ginger

A Haskell implementation of the Jinja template language.
MIT License
77 stars 13 forks source link

Local date handling accounting for DST #70

Closed mulderr closed 3 years ago

mulderr commented 3 years ago

I assume it is somewhat a best practice to store dates in UTC and only convert to local for presentation purposes which is what I ultimately decided to do. Therefore I'd like to pass UTC to ginger and have it properly convert and format it as local time. However, this leads to slight problems/inconveniences down the line stemming both from upstream and ginger itself.

First, to start at the root, time library currently implements a simplistic notion of time zones as offsets from UTC. This is only correct in absence of DST. For example Europe/Berlin is a time zone with offset either +0100 (without) or +0200 (with DST) but for time there is no Europe/Berlin, there are just two distinct offsets which it both represents as separate TimeZone (a misnomer really) values. Therefore to calculate local time from UTC using utcToLocalTime or utcToZonedTime you need to know if DST was in effect for your chosen time zone and particular date combination.

AFAIK the only exception to this is when you want to calculate local time according to your local system time zone settings. This is conveniently possible using functions like getTimeZone :: UTCTime -> IO TimeZone, utcToLocalZonedTime :: UTCTime -> IO ZonedTime and would be enough for my purposes.

Going back to ginger, currently my only choice is to convert everything to LocalTime or ZonedTime before rendering or maybe attach an offset to each date so that I can pass it to dateformat. Both seem rather unsatisfactory because the dates, all represented as UTCTime, may be embedded deeply inside the data types.

To handle this once and for all I thought about adding a localdate builtin function to ginger for exactly this use case but it would require IO. I'm unsure if that would fit well into ginger. The default scope only consist of pure functions.

mulderr commented 3 years ago
gfnLocalDateFormat :: Monad m => Function (Run p m h)
gfnLocalDateFormat args =
    let extracted =
            extractArgsDefL
                [ ("date", def)
                , ("format", def)
                , ("locale", def)
                ]
                args
    in case extracted of
        Right [gDate, gFormat, gLocale] ->
            let fmtMay = Text.unpack <$> fromGVal gFormat
                dateMay = convertToLocal <$> gvalToDate utc gDate
            in case fmtMay of
                Just fmt -> do
                    locale <- maybe
                        (getTimeLocale gLocale)
                        return
                        (fromGVal gLocale)
                    return . toGVal $ formatTime locale fmt <$> dateMay
                Nothing -> do
                    return . toGVal $ dateMay
        _ -> throwHere $ ArgumentsError (Just "localdate") "expected: (date, format, locale=null)"
    where
        convertToLocal :: ZonedTime -> ZonedTime
        convertToLocal = unsafePerformIO . utcToLocalZonedTime . zonedTimeToUTC

Above gives the desired effect, alas at the cost of unsafePerformIO, so pasting it here just for reference.

mulderr commented 3 years ago

To keep things pure we would likely have to depend on timezone-series and have a version of dateformat that:

Another alternative could be tz. Both rather niche. I don't see a "nice" solution here :/

tdammers commented 3 years ago

IMO, the situation as it is now is already close to the best we can do. Ginger can, out of the box, deal with the de facto standard Haskell date/time library, it can format both UTC timestamps and local timestamps, provided the latter are pre-converted to the correct UTC offset. I don't think dateformat is the problem here, the formatting is fine.

What you are running into is the fact that Ginger will not do timezone conversions for you. If you want that, then IMO your best options are:

  1. Do the conversions in the host code, and pass pre-converted ZonedTime values to Ginger.
  2. Select a single timezone, write a timezone conversion function that closes over all the information it needs, and hook that up as a Ginger function; now you can do the conversions in Ginger. Constructing the Ginger context will require IO, but template expansion itself will not, and there's no need for unsafePerformIO inside the date conversion function either.
  3. Same as 2, but give that function an additional parameter that allows you to select the timezone from within Ginger. This is only necessary if you need to display times in different timezones on the same page.

Options 2 and 3 could easily be provided as add-on libraries to Ginger, so I don't feel very compelled to include this in the core library, especially if it involves another dependency.

On a side note: if you look at how getlocale is used in the current getTimeLocale Ginger function (Builtins.hs, line 724), you might imagine how the local UTC time and timezone data could be facilitated in a similar manner. Essentially, what this function does is it checks for a function named getlocale in the global scope; if it exists, then it calls it to get the desired locale, and if not, it falls back to the default C locale. The same could be done for local time, except that instead of falling back on a default value, it would probably have to error out. And then you can choose: you can have your template run in IO, and write a currenttime function that gets the current time on the fly, in IO, or you can get the current time in advance, close over it, and have a currenttime function in the Identity monad that just returns the predetermined timestamp.

mulderr commented 3 years ago

Thanks, option 2 seems plausible. I will try to figure out how to pass an IO based function inside the context then.

BTW. there is more to this then I originally thought. Neither timezone-olson nor tz can fully understand binary Olson files. They don't even attempt to parse POSIX-TZ strings so while historic dates will probably be as good as they can be, future dates (say 2038) may be converted differently to the unix date utility etc. So as it stands those would not provide a complete solution either.

tdammers commented 3 years ago

Well, future dates are subject to speculation anyway; timezone data beyond 2 weeks into the future is pretty much "as far as we know, but might change on a politician's whim at any time".

On Thu, 11 Nov 2021, 17:55 mulderr, @.***> wrote:

Thanks, option 2 seems plausible. I will try to figure out how to pass an IO based function inside the context then.

BTW. there is more to this then I originally thought. Neither timezone-olson nor tz can fully understand binary Olson files. They don't event attempt to parse POSIX-TZ strings so while historic dates will probably be as good as they can be, future dates (say 2038) may be converted differently to the unix date utility etc. So as it stands those would not provide a complete solution either.

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/tdammers/ginger/issues/70#issuecomment-966461394, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAK6HR7Q65FPZYQ3S65QWLTULPYOVANCNFSM5HYGHCSQ . Triage notifications on the go with GitHub Mobile for iOS https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675 or Android https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub.