Skip to content

Commit 58ed41b

Browse files
committed
Add timezone conversion support via external providers
1 parent 469b366 commit 58ed41b

File tree

3 files changed

+228
-75
lines changed

3 files changed

+228
-75
lines changed

README.md

+58-32
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,17 @@ Only run a task past a certain time of day, only accept submissions since a cert
66

77
Written in almost pure Gleam, Tempo tries to optimize for the same thing the Gleam language does: explicitness over terseness and simplicity over convenience. My hope is to make Tempo feel like the Gleam language and to make it as difficult to write time related bugs as possible.
88

9+
Supports both the Erlang and JavaScript targets.
10+
911
## Installation
1012

1113
```sh
1214
gleam add gtempo
1315
```
16+
Supports timezones only through the `gtz` package. Add it with:
17+
```sh
18+
gleam add gtz
19+
```
1420

1521
[![Package Version](https://img.shields.io/hexpm/v/tempo)](https://hex.pm/packages/gtempo)
1622
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/gtempo/)
@@ -34,26 +40,29 @@ pub fn main() {
3440
}
3541
```
3642

37-
#### Iterating Over a Date Range Example
43+
#### Time Zone Conversion Example
3844

3945
```gleam
40-
import gleam/iterator
41-
import tempo/date
42-
import tempo/period
46+
import gtz
47+
import tempo/datetime
4348
4449
pub fn main() {
45-
date.literal("2024-06-21")
46-
|> date.difference(from: date.literal("2024-06-24"))
47-
|> period.comprising_dates
48-
|> iterator.to_list
49-
// -> [2024-06-21, 2024-06-22, 2024-06-23, 2024-06-24]
50+
let assert Ok(local_tz) = gtz.local_name() |> gtz.timezone
5051
51-
date.literal("2024-06-21")
52-
|> date.difference(from: date.literal("2024-07-08"))
53-
|> period.comprising_months
54-
|> iterator.to_list
55-
// -> [tempo.Jun, tempo.Jul]
52+
datetime.from_unix_utc(1_729_257_776)
53+
|> datetime.to_timezone(local_tz)
54+
|> datetime.to_string
55+
|> io.println
56+
57+
let assert Ok(tz) = gtz.timezone("America/New_York")
58+
59+
datetime.literal("2024-01-03T05:30:02.334Z")
60+
|> datetime.to_timezone(tz)
61+
|> datetime.to_string
62+
|> io.println
5663
}
64+
// -> "2024-10-18T14:22:56.000+01:00"
65+
// -> "2024-01-03T00:30:02.334-05:00"
5766
```
5867

5968
#### Time-Based Logical Branching and Logging Example
@@ -84,10 +93,9 @@ pub fn main() {
8493
|> duration.as_minutes
8594
|> int.to_string
8695
<> " minutes! This should take until "
87-
<> time.now_utc()
88-
|> time.add(duration.minutes(16))
89-
|> time.to_milli_precision
90-
|> time.to_string
96+
<> datetime.now_utc()
97+
|> datetime.add(duration.minutes(16))
98+
|> datetime.to_string
9199
<> " UTC",
92100
)
93101
@@ -97,10 +105,9 @@ pub fn main() {
97105
False -> {
98106
io.println(
99107
"No rush :) This should take until "
100-
<> time.now_local()
101-
|> time.add(duration.hours(3))
102-
|> time.to_second_precision
103-
|> time.to_string,
108+
<> datetime.now_local()
109+
|> datetime.add(duration.hours(3))
110+
|> datetime.to_string,
104111
)
105112
106113
run_long_task(for: date.current_local())
@@ -115,6 +122,28 @@ pub fn main() {
115122
// -> Phew, that only took 978 microseconds
116123
```
117124

125+
#### Iterating Over a Date Range Example
126+
127+
```gleam
128+
import gleam/iterator
129+
import tempo/date
130+
import tempo/period
131+
132+
pub fn main() {
133+
date.literal("2024-06-21")
134+
|> date.difference(from: date.literal("2024-06-24"))
135+
|> period.comprising_dates
136+
|> iterator.to_list
137+
// -> [2024-06-21, 2024-06-22, 2024-06-23, 2024-06-24]
138+
139+
date.literal("2024-06-21")
140+
|> date.difference(from: date.literal("2024-07-08"))
141+
|> period.comprising_months
142+
|> iterator.to_list
143+
// -> [tempo.Jun, tempo.Jul]
144+
}
145+
```
146+
118147
#### Waiting Until a Specific Time Example
119148

120149
```gleam
@@ -138,30 +167,27 @@ Further documentation can be found at <https://hexdocs.pm/gtempo>.
138167

139168
## Time Zone and Leap Second Considerations
140169

141-
This package purposefully **ignores leap seconds** and **will not convert between time zones**. Try to design your application so time zones do not have to be converted between and leap seconds are trivial. More below.
170+
This package purposefully **ignores leap seconds** and **will not convert between time zones unless given a timezone provider**. Try to design your application so time zones do not have to be converted between and leap seconds are trivial. More below.
142171

143-
Both time zones and leap seconds require maintaining a manually updated database of location offsets and leap seconds. This burdens any application that uses them to keep their dependencies up to date and burdens the package by invalidating all previous versions when an update needs to be made.
172+
Both time zones and leap seconds require maintaining a manually updated database of location offsets and leap seconds. This burdens any application that uses them to keep their dependencies up to date and burdens the package by either invalidating all previous versions when an update needs to be made or providing hot timezone data updates.
144173

145-
If at all possible, try to design your application so that time zones do not have to be converted between. Client machines should have information about their time zone offset that can be polled and used for current time time zone conversions. This package will allow you to convert between local time and UTC time on the same date as the system date.
174+
If at all possible, try to design your application so that time zones do not have to be converted between. Client machines should have information about their time zone offset that can be polled and used for current time time zone conversions. This package will allow you to convert between local time and UTC time on the same date as the system date natively.
146175

147176
Since this package ignores leap seconds, historical leap seconds are ignored when doing comparisons and durations. Please keep this in mind when designing your applications. Leap seconds can still be parsed from ISO 8601 strings and will be compared correctly to other times, but will not be preserved when converting to any other time representation (including changing the offset).
148177

149178
When getting the system time, leap second accounting depends on the host's time implementation.
150179

151-
If you must, to convert between time zones with this package, use a separate time zone provider package to get the offset of the target time zone for a given date, then apply the offset to a `datetime` value (this time zone package is fictional):
180+
If you must, to convert between time zones with this package, use a separate time zone provider package like `gtz`:
152181

153182
```gleam
154183
import tempo/datetime
155-
import timezone
184+
import gtz
156185
157186
pub fn main() {
158187
let convertee = datetime.literal("2024-06-12T10:47:00.000Z")
159188
160-
convertee
161-
|> datetime.to_offset(timezone.offset(
162-
for: "America/New_York",
163-
on: convertee,
164-
))
189+
datetime.literal("2024-01-03T05:30:02.334Z")
190+
|> datetime.to_timezone(tz)
165191
|> datetime.to_string
166192
// -> "2024-06-12T06:47:00.000-04:00"
167193
}

src/tempo.gleam

+103-18
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,26 @@ import gtempo/internal as unit
2525
// 10. Tempo module logic
2626
// 11. FFI logic
2727

28-
fn tz() {
29-
todo as "Add tz support to Offsets via external providers"
30-
}
31-
3228
// -------------------------------------------------------------------------- //
3329
// DateTime Logic //
3430
// -------------------------------------------------------------------------- //
3531

3632
/// A datetime value with a timezone offset associated with it. It has the
3733
/// most amount of information about a point in time, and can be compared to
38-
/// all other types in this package.
34+
/// all other types in this package by getting its lesser parts.
3935
pub opaque type DateTime {
4036
DateTime(naive: NaiveDateTime, offset: Offset)
37+
LocalDateTime(naive: NaiveDateTime, offset: Offset, tz: TimeZoneProvider)
38+
}
39+
40+
/// A type for external packages to provide so that datetimes can be converted
41+
/// between timezones. The package `gtz` was created to provide this and must
42+
/// be installed separately.
43+
pub type TimeZoneProvider {
44+
TimeZoneProvider(
45+
get_name: fn() -> String,
46+
calculate_offset: fn(NaiveDateTime) -> Offset,
47+
)
4148
}
4249

4350
@internal
@@ -55,6 +62,43 @@ pub fn datetime_get_offset(datetime: DateTime) {
5562
datetime.offset
5663
}
5764

65+
@internal
66+
pub fn datetime_to_utc(datetime: DateTime) -> DateTime {
67+
datetime
68+
|> datetime_apply_offset
69+
|> naive_datetime_set_offset(utc)
70+
}
71+
72+
@internal
73+
pub fn datetime_to_offset(datetime: DateTime, offset: Offset) -> DateTime {
74+
datetime
75+
|> datetime_to_utc
76+
|> datetime_subtract(offset_to_duration(offset))
77+
|> datetime_drop_offset
78+
|> naive_datetime_set_offset(offset)
79+
}
80+
81+
@internal
82+
pub fn datetime_to_tz(datetime: DateTime, tz: TimeZoneProvider) {
83+
let utc_dt = datetime_apply_offset(datetime)
84+
85+
let offset = tz.calculate_offset(utc_dt)
86+
87+
let naive =
88+
datetime_to_offset(utc_dt |> naive_datetime_set_offset(utc), offset)
89+
|> datetime_drop_offset
90+
91+
LocalDateTime(naive:, offset:, tz:)
92+
}
93+
94+
@internal
95+
pub fn datetime_get_tz(datetime: DateTime) -> option.Option(String) {
96+
case datetime {
97+
DateTime(_, _) -> None
98+
LocalDateTime(_, _, tz:) -> Some(tz.get_name())
99+
}
100+
}
101+
58102
@internal
59103
pub fn datetime_compare(a: DateTime, to b: DateTime) {
60104
datetime_apply_offset(a)
@@ -78,21 +122,19 @@ pub fn datetime_is_later_or_equal(a: DateTime, to b: DateTime) -> Bool {
78122

79123
@internal
80124
pub fn datetime_apply_offset(datetime: DateTime) -> NaiveDateTime {
81-
let original_time = datetime.naive.time
82-
83125
let applied =
84126
datetime
85-
|> datetime_add(offset_to_duration(datetime.offset))
86127
|> datetime_drop_offset
128+
|> naive_datetime_add(offset_to_duration(datetime.offset))
87129

88130
// Applying an offset does not change the abosolute time value, so we need
89-
// to preserve the monotonic and unique values.
131+
// to preserve the monotonic and unique values.zzzz
90132
NaiveDateTime(
91133
date: applied.date,
92134
time: Time(
93135
..{ applied.time },
94-
monotonic: original_time.monotonic,
95-
unique: original_time.unique,
136+
monotonic: datetime.naive.time.monotonic,
137+
unique: datetime.naive.time.unique,
96138
),
97139
)
98140
}
@@ -107,10 +149,53 @@ pub fn datetime_add(
107149
datetime: DateTime,
108150
duration duration_to_add: Duration,
109151
) -> DateTime {
110-
datetime
111-
|> datetime_drop_offset
112-
|> naive_datetime_add(duration: duration_to_add)
113-
|> naive_datetime_set_offset(datetime.offset)
152+
case datetime {
153+
DateTime(naive:, offset:) ->
154+
DateTime(
155+
naive: naive_datetime_add(naive, duration: duration_to_add),
156+
offset:,
157+
)
158+
LocalDateTime(_, _, tz:) -> {
159+
let utc_dt_added =
160+
datetime_to_utc(datetime)
161+
|> datetime_add(duration: duration_to_add)
162+
163+
let offset = utc_dt_added |> datetime_drop_offset |> tz.calculate_offset
164+
165+
let naive =
166+
datetime_to_offset(utc_dt_added, offset)
167+
|> datetime_drop_offset
168+
169+
LocalDateTime(naive:, offset:, tz:)
170+
}
171+
}
172+
}
173+
174+
@internal
175+
pub fn datetime_subtract(
176+
datetime: DateTime,
177+
duration duration_to_subtract: Duration,
178+
) -> DateTime {
179+
case datetime {
180+
DateTime(naive:, offset:) ->
181+
DateTime(
182+
naive: naive_datetime_subtract(naive, duration: duration_to_subtract),
183+
offset:,
184+
)
185+
LocalDateTime(_, _, tz:) -> {
186+
let utc_dt_sub =
187+
datetime_to_utc(datetime)
188+
|> datetime_subtract(duration: duration_to_subtract)
189+
190+
let offset = utc_dt_sub |> datetime_drop_offset |> tz.calculate_offset
191+
192+
let naive =
193+
datetime_to_offset(utc_dt_sub, offset)
194+
|> datetime_drop_offset
195+
196+
LocalDateTime(naive:, offset:, tz:)
197+
}
198+
}
114199
}
115200

116201
// -------------------------------------------------------------------------- //
@@ -310,8 +395,8 @@ pub fn new_offset(offset_minutes minutes: Int) -> Result(Offset, Nil) {
310395
pub fn offset_from_string(offset: String) -> Result(Offset, OffsetParseError) {
311396
use offset <- result.try(case offset {
312397
// Parse Z format
313-
"Z" -> Ok(Offset(0))
314-
"z" -> Ok(Offset(0))
398+
"Z" -> Ok(utc)
399+
"z" -> Ok(utc)
315400

316401
// Parse +-hh:mm format
317402
_ -> {
@@ -354,7 +439,7 @@ pub fn offset_from_string(offset: String) -> Result(Offset, OffsetParseError) {
354439
})
355440

356441
case sign, int.parse(hour), int.parse(minute) {
357-
_, Ok(0), Ok(0) -> Ok(Offset(0))
442+
_, Ok(0), Ok(0) -> Ok(utc)
358443
"-", Ok(hour), Ok(minute) if hour <= 24 && minute <= 60 ->
359444
Ok(Offset(-{ hour * 60 + minute }))
360445
"+", Ok(hour), Ok(minute) if hour <= 24 && minute <= 60 ->

0 commit comments

Comments
 (0)