tztime
Safe timezone-aware handling of time.
https://github.com/serokell/tztime
| LTS Haskell 24.16: | 0.1.1.0 |
| Stackage Nightly 2025-10-24: | 0.1.1.0 |
| Latest on Hackage: | 0.1.1.0 |
tztime-0.1.1.0@sha256:6231f710c3e033aaa60af67aaa9f712fff8a3aeb960f7d74b3acd34b85b8a75b,6873Module documentation for 0.1.1.0
tztime
This package introduces:
- The
TZTimedata type, a valid and unambiguous point in time in some time zone. - Functions for safely manipulating a
TZTime.
Table of contents:
Examples
import Control.Arrow ((>>>))
import Data.Aeson
import Data.Aeson.QQ.Simple
import Data.Aeson.TH
import Data.Function ((&))
import Data.Time
import Data.Time.Clock.POSIX
import Data.Time.Compat
import Data.Time.TZInfo as TZI
import Data.Time.TZTime as TZ
import Data.Time.TZTime.QQ (tz)
Manipulating a TZTime
λ> [tz|2022-03-04 10:15:00 +01:00 [Europe/Rome]|] & modifyLocal (
addCalendarClip (calendarMonths 2 <> calendarDays 3) >>>
atFirstDayOfWeekOnAfter Wednesday >>>
atMidnight
)
2022-05-11 00:00:00 +02:00 [Europe/Rome]
In spring in Havana, the clocks turn forward from 23:59 to 01:00. So that day does not start at midnight, it starts at 01:00.
λ> atStartOfDay [tz|2022-03-13 10:45:00 [America/Havana]|]
2022-03-13 01:00:00 -04:00 [America/Havana]
In spring in Australia/Lord_Howe, the clocks turn forward 30 minutes at 02:00. So 3 hours past 01:00 would actually be 04:30.
λ> :{
addTime (hours 3) $
TZ.fromLocalTime (TZI.fromLabel Australia__Lord_Howe) $
LocalTime (YearMonthDay 2022 10 2) (TimeOfDay 1 0 0)
:}
2022-10-02 04:30:00 +11:00 [Australia/Lord_Howe]
Encoding / Decoding
Because there’s no standard format for encoding a timezone identifier alongside a date/time/offset, most systems encode these two separately.
For example, if you’re encoding to/decoding from JSON, you’ll probably want to split a TZTime into
a POSIXTime/UTCTime/ZonedTime and a TZIdentifier, and encode them as separate fields.
data Message = MkMessage { sender :: Text, time :: ZonedTime, timezone :: TZIdentifier }
deriveFromJSON defaultOptions ''Message
To re-build a TZTime, you have to first fetch the time zone rules TZInfo for the given TZIdentifier
from the system’s time zone database using loadFromSystem (or from the library’s embedded database using fromIdentifier).
λ> json = encode [aesonQQ| { "sender": "john doe", "time": "2022-03-04T10:15:00+01:00", "timezone": "Europe/Rome" }|]
λ> Right message = eitherDecode' @Message json
λ> tzInfo <- TZI.loadFromSystem (timezone message)
λ> TZ.fromZonedTime tzInfo (time message)
2022-03-04 10:15:00 +01:00 [Europe/Rome]
If the encoded data is not meant to be read by other systems (and thus interoperability is not a concern),
then using the Show/Read instances to encode/decode as a single field is also an option.
λ> show [tz|2022-03-04 10:15:00 +01:00 [Europe/Rome]|]
"2022-03-04 10:15:00 +01:00 [Europe/Rome]"
λ> read @TZTime "2022-03-04 10:15:00 +01:00 [Europe/Rome]"
2022-03-04 10:15:00 +01:00 [Europe/Rome]
Motivation
Note: We’ll use the packages time, time-compat and tz for the examples below.
λ> :m +Data.Time.LocalTime Data.Time.Clock Data.Time.Calendar.Compat
λ> import qualified Data.Time.Zones as TZ
λ> import qualified Data.Time.Zones.All as TZ
Manipulating time in a given time zone is not trivial.
Depending on what you need to do, it may make sense to modify the local time-line
(i.e. using time’s LocalTime),
or the universal time-line (i.e. convert the time to UTCTime, modify it, convert it back
to LocalTime).
For example, if you want to add a certain number of hours to the current time, then modifying the local time-line may end up adding more time than you intended:
λ> -- It's 00:30 on 2022-11-06 in the America/Winnipeg time zone.
λ> tz = TZ.tzByLabel TZ.America__Winnipeg
λ> t1 = LocalTime (YearMonthDay 2022 11 6) (TimeOfDay 0 30 0)
λ> t1
2022-11-06 00:30:00
λ> -- We naively add 4 hours to the local time.
λ> t2 = addLocalTime (secondsToNominalDiffTime 4 * 60 * 60) t1
λ> t2
2022-11-06 04:30:00
λ> -- Let's use the `tz` package to convert these times to UTC and
λ> -- see how many hours have actually passed between t1 and t2.
λ> TZ.LTUUnique t1utc _ = TZ.localTimeToUTCFull tz t1
λ> TZ.LTUUnique t2utc _ = TZ.localTimeToUTCFull tz t2
λ> nominalDiffTimeToSeconds (diffUTCTime t2utc t1utc) / 60 / 60
5.000000000000
We’ve accidentally landed 5 hours ahead, not 4 as we wanted. This happened because on that day, at 01:59, the America/Winnipeg time zone switched from the CDT offset (UTC-5) to the CST offset (UTC-6). In other words, the clocks were turned back 1 hour, back to 01:00:00.
If the clocks had been turned forward, as is done in many time zones in spring, then we would have added only 3 hours instead of 4.
If we add just 1 hour, we’ll run into yet another issue:
λ> -- We naively add 1 hour to the local time.
λ> t2 = addLocalTime (secondsToNominalDiffTime 1 * 60 * 60) t1
λ> t2
2022-11-06 01:30:00
λ> -- Let's try converting t2 to UTC
λ> TZ.localTimeToUTCFull tz t2
LTUAmbiguous
{ _ltuFirst = 2022-11-06 06:30:00 UTC
, _ltuSecond = 2022-11-06 07:30:00 UTC
, _ltuFirstZone = CDT
, _ltuSecondZone = CST
}
We landed on an ambiguous local time. Since the clocks were turned back, there’s an overlap: the time 01:30 happened twice on that day. Once at UTC-5 and again at UTC-6.
On the other hand, when the clock are turned forward, there is a gap in the local time-line and we risk
accidentally constructing an invalid LocalTime that never occurred in that time zone.
For these reasons, when adding a certain number of hours/minutes/seconds, you probably want to do it in the universal time-line instead.
Now say you want to add a certain number of days instead. Doing that on the universal time-line may end up working not quite as expected:
λ> -- It's 23:30 on 2022-03-12 in the America/Winnipeg time zone.
λ> tz = TZ.tzByLabel TZ.America__Winnipeg
λ> t1 = LocalTime (YearMonthDay 2022 3 12) (TimeOfDay 23 30 0)
λ> -- Convert to UTC, add 1 day, convert back to our time zone.
λ> TZ.LTUUnique t1utc _ = TZ.localTimeToUTCFull tz t1
λ> t2utc = addUTCTime nominalDay t1utc
λ> TZ.utcToLocalTimeTZ tz t2utc
2022-03-14 00:30:00
We’ve accidentally landed two days ahead instead of just one. This happened because, on 2022-03-13, the clocks were turned forward 1 hour, so that day only had 23 hours on that time zone. Adding 24 hours on the universal time-line ended up being too much.
In the local time-line, some days may have 23 hours, 25 hours, 23 hours and 30 minutes (e.g. in the Australia/Lord_Howe time zone), etc, depending on the offset transitions defined by each time zone.
For this reason, when you want to add days/weeks/months/years, you probably want to do it in the local time-line and then check if you landed on a gap or an overlap and correct accordingly.
This package aims to:
- make it easier to do “the right thing” and harder to do “the wrong thing”.
- ensure you don’t accidentally end up with an invalid or ambiguous local time.
Here’s how you’d do the above using tztime:
λ> import Data.Time.TZTime
λ> import Data.Time.TZTime.QQ (tz)
λ> t1 = [tz|2022-11-06 00:30:00 [America/Winnipeg]|]
λ> t2 = addTime (hours 4) t1
λ> t2
2022-11-06 03:30:00 -06:00 [America/Winnipeg]
λ> nominalDiffTimeToSeconds (diffTZTime t2 t1) / 60 / 60
4.000000000000
λ> t1 = [tz|2022-03-12 23:30:00 [America/Winnipeg]|]
λ> modifyLocal (addCalendarClip (calendarDays 1)) t1
2022-03-13 23:30:00 -05:00 [America/Winnipeg]