Skip to content

Commit ccb5fd1

Browse files
committed
Add a routine to convert time and date to 0-2 timestamps given a time zone name
1 parent 3dd3f9d commit ccb5fd1

File tree

5 files changed

+265
-2
lines changed

5 files changed

+265
-2
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## Upcoming
2+
- Add a `tzcalendar.from_calendar` function. Test shifts around daylight savings
3+
time.
4+
- Fixed an issue with the time zone change where comparisons of the timestamp to
5+
the transition times with a greater than, and it should have been greater than
6+
or equal to.
7+
18
## v1.0.1 - 2025-09-23
29
- Fix bug where the library will crash if asked to load Time Zone information
310
from a nonexistant directory.

gleam.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name = "tzif"
2-
version = "1.0.1"
2+
version = "1.1.0"
33

44
# Fill out these fields if you intend to generate HTML documentation or publish
55
# your project to the Hex package manager.

src/tzif/database.gleam

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ fn get_slice(
264264

265265
slices
266266
|> list.fold_until(default, fn(acc, slice) {
267-
case slice.start_time < seconds {
267+
case slice.start_time <= seconds {
268268
True -> list.Continue(Ok(slice))
269269
False -> list.Stop(acc)
270270
}

src/tzif/tzcalendar.gleam

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
//// article on Wikipedia. The time zone identifiers are passed to many of the
1717
//// functions of this library as the `zone_name` parameter.
1818

19+
import gleam/list
1920
import gleam/result
2021
import gleam/time/calendar.{type Date, type TimeOfDay}
2122
import gleam/time/duration.{type Duration}
@@ -108,3 +109,59 @@ pub fn to_calendar(
108109

109110
#(tiz.date, tiz.time_of_day)
110111
}
112+
113+
/// Create a list of timestamps from a calendar date and time. This may produce zero, one
114+
/// or two timestamps in a list depending on if that time is possible in the given
115+
/// time zone or if it is ambiguous. This tends to happen every daylight saving
116+
/// period. When moving forward one hour, the calendar times during the skipped hour
117+
/// are not possible, so no timestamp values would be returned. When moving back
118+
/// one hour the overlap period is repeated, yielding two timestamps per wall clock time.
119+
///
120+
/// This
121+
/// returns a `TzDatabaseError` if there is an issue finding time zone information.
122+
pub fn from_calendar(
123+
date: calendar.Date,
124+
time: calendar.TimeOfDay,
125+
zone_name: String,
126+
db: TzDatabase,
127+
) -> Result(List(timestamp.Timestamp), database.TzDatabaseError) {
128+
// Assume no shift will be more than 24 hours
129+
let ts_utc = timestamp.from_calendar(date, time, duration.seconds(0))
130+
131+
// What are the offsets at +/- the 24 hour window
132+
use before_zone <- result.try(
133+
timestamp.add(ts_utc, duration.hours(-24))
134+
|> database.get_zone_parameters(zone_name, db),
135+
)
136+
use after_zone <- result.try(
137+
timestamp.add(ts_utc, duration.hours(24))
138+
|> database.get_zone_parameters(zone_name, db),
139+
)
140+
141+
case before_zone.offset == after_zone.offset {
142+
True -> {
143+
// No time shift in the period of interest. Easy conversion.
144+
Ok([timestamp.from_calendar(date, time, before_zone.offset)])
145+
}
146+
False -> {
147+
// Check that we get the same UTC offset if we convert the date and
148+
// time to a timestamp using that offset and then find the offset
149+
// of the time zone at that timestamp. This is a round trip from utc
150+
// offset back to utc offset which should yield the same value if the utc
151+
// offset we use is correct for the time zone at that time and date.
152+
[before_zone.offset, after_zone.offset]
153+
|> list.filter(fn(offset) {
154+
let zone =
155+
timestamp.from_calendar(date, time, offset)
156+
|> database.get_zone_parameters(zone_name, db)
157+
case zone {
158+
Ok(database.ZoneParameters(round_trip_offset, _, _)) ->
159+
offset == round_trip_offset
160+
Error(_) -> False
161+
}
162+
})
163+
|> list.map(fn(offset) { timestamp.from_calendar(date, time, offset) })
164+
|> Ok
165+
}
166+
}
167+
}

test/tzif/tzcalendar_test.gleam

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,22 @@ const tzsample = "VFppZjIAAAAAAAAAAAAAAAAAAAAAAAAGAAAABgAAAAAAAADsAAAABgAAABSAAA
1010

1111
const tzsample2 = "VFppZjIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAQAAAAAAABVVEMAVFppZjIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAQAAAAAAABVVEMAClVUQzAK"
1212

13+
const canberra = "VFppZjIAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAACOAAAABAAAAA6AAAAAnE7CgJy8LwDLVLMAy8dlgMy3VoDNp0eAzqBzAM+HKYADcDmABA0cAAVQG4AF9jiABy/9gAfWGoAJD9+ACbX8gArvwYALnxkADNjeAA1++wAOuMAAD17dABCYogARPr8AEniEABMeoQAUWGYAFP6DABY4SAAXDImAGCFkgBjHgYAaAUaAGqdjgBvhKIAch0WAHcEKgB55nIAfl7IAIFl+gCGAzoAiQpsAI2nrACQifQAlSc0AJe/qACcprwAnz8wAKQmRACmvrgAq6XMAK5jKgCzSj4AteKyALrJxgC9YjoAwklOAMV1agDJyNYAzPTyANFIXgDUdHoA2MfmANv0AgDgbFgA43OKAOafpgDq8xIA72toAPKXhAD26vAA+hcMAP5qeAEBlpQBBg7qAQkWHAENjnIBELqOARUN+gEYFSwBHI2CAR/eiAEjnkwBJ14QASsd1AEu3ZgBMp1cATZdIAE6HOQBPdyoAUHBVgFFgRoBSUDeAU0AogFQwGYBVIAqAVg/7gFb/7IBX792AWN/OgFnPv4Bav7CAW7jcAFyozQBdmL4AXoivAF94oABgaJEAYViCAGJIcwBjOGQAZChVAGUYRgBmEXGAZwFigGfxU4Bo4USAadE1gGrBJoBrsReAbKEIgG2Q+YBugOqAb3DbgHBqBwBxWfgAcknpAHM52gB0KcsAdRm8AHYJrQB2+Z4Ad+mPAHjZgAB5yXEAerliAHuyjYB8on6AfZJvgH6CYIB/clGAAwECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQAAjcQAAAAAmrABBAAAjKAACQAAjKAACUxNVABBRURUAEFFU1QAAAEBAFRaaWYyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAjgAAAAQAAAAO/////3MWfzz/////nE7CgP////+cvC8A/////8tUswD/////y8dlgP/////Mt1aA/////82nR4D/////zqBzAP/////PhymAAAAAAANwOYAAAAAABA0cAAAAAAAFUBuAAAAAAAX2OIAAAAAABy/9gAAAAAAH1hqAAAAAAAkP34AAAAAACbX8gAAAAAAK78GAAAAAAAufGQAAAAAADNjeAAAAAAANfvsAAAAAAA64wAAAAAAAD17dAAAAAAAQmKIAAAAAABE+vwAAAAAAEniEAAAAAAATHqEAAAAAABRYZgAAAAAAFP6DAAAAAAAWOEgAAAAAABcMiYAAAAAAGCFkgAAAAAAYx4GAAAAAABoBRoAAAAAAGqdjgAAAAAAb4SiAAAAAAByHRYAAAAAAHcEKgAAAAAAeeZyAAAAAAB+XsgAAAAAAIFl+gAAAAAAhgM6AAAAAACJCmwAAAAAAI2nrAAAAAAAkIn0AAAAAACVJzQAAAAAAJe/qAAAAAAAnKa8AAAAAACfPzAAAAAAAKQmRAAAAAAApr64AAAAAACrpcwAAAAAAK5jKgAAAAAAs0o+AAAAAAC14rIAAAAAALrJxgAAAAAAvWI6AAAAAADCSU4AAAAAAMV1agAAAAAAycjWAAAAAADM9PIAAAAAANFIXgAAAAAA1HR6AAAAAADYx+YAAAAAANv0AgAAAAAA4GxYAAAAAADjc4oAAAAAAOafpgAAAAAA6vMSAAAAAADva2gAAAAAAPKXhAAAAAAA9urwAAAAAAD6FwwAAAAAAP5qeAAAAAABAZaUAAAAAAEGDuoAAAAAAQkWHAAAAAABDY5yAAAAAAEQuo4AAAAAARUN+gAAAAABGBUsAAAAAAEcjYIAAAAAAR/eiAAAAAABI55MAAAAAAEnXhAAAAAAASsd1AAAAAABLt2YAAAAAAEynVwAAAAAATZdIAAAAAABOhzkAAAAAAE93KgAAAAAAUHBVgAAAAABRYEaAAAAAAFJQN4AAAAAAU0AogAAAAABUMBmAAAAAAFUgCoAAAAAAVg/7gAAAAABW/+yAAAAAAFfv3YAAAAAAWN/OgAAAAABZz7+AAAAAAFq/sIAAAAAAW7jcAAAAAABcqM0AAAAAAF2YvgAAAAAAXoivAAAAAABfeKAAAAAAAGBokQAAAAAAYViCAAAAAABiSHMAAAAAAGM4ZAAAAAAAZChVAAAAAABlGEYAAAAAAGYRcYAAAAAAZwFigAAAAABn8VOAAAAAAGjhRIAAAAAAadE1gAAAAABqwSaAAAAAAGuxF4AAAAAAbKEIgAAAAABtkPmAAAAAAG6A6oAAAAAAb3DbgAAAAABwagcAAAAAAHFZ+AAAAAAAcknpAAAAAABzOdoAAAAAAHQpywAAAAAAdRm8AAAAAAB2Ca0AAAAAAHb5ngAAAAAAd+mPAAAAAAB42YAAAAAAAHnJcQAAAAAAerliAAAAAAB7so2AAAAAAHyifoAAAAAAfZJvgAAAAAB+gmCAAAAAAH9yUYADAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAACNxAAAAACasAEEAACMoAAJAACMoAAJTE1UAEFFRFQAQUVTVAAAAQEACkFFU1QtMTBBRURULE0xMC4xLjAsTTQuMS4wLzMK"
14+
1315
fn get_database() -> database.TzDatabase {
1416
let assert Ok(tzdata) = bit_array.base64_decode(tzsample)
1517
let assert Ok(tz_ny) = parser.parse(tzdata)
1618

1719
let assert Ok(tzdata2) = bit_array.base64_decode(tzsample2)
1820
let assert Ok(tz_utc) = parser.parse(tzdata2)
1921

22+
let assert Ok(tzdata3) = bit_array.base64_decode(canberra)
23+
let assert Ok(tz_au) = parser.parse(tzdata3)
24+
2025
database.new()
2126
|> database.add_tzfile("America/New_York", tz_ny)
2227
|> database.add_tzfile("UTC", tz_utc)
28+
|> database.add_tzfile("Australia/Canberra", tz_au)
2329
}
2430

2531
pub fn get_time_in_local_zone_test() {
@@ -67,3 +73,196 @@ pub fn get_time_only_in_utc_zone_test() {
6773
calendar.TimeOfDay(5, 18, 0, 0),
6874
))
6975
}
76+
77+
pub fn timestamp_from_calendar_test() {
78+
let db = get_database()
79+
assert tzcalendar.from_calendar(
80+
calendar.Date(2025, calendar.January, 23),
81+
calendar.TimeOfDay(13, 0, 0, 0),
82+
"America/New_York",
83+
db,
84+
)
85+
== Ok([timestamp.from_unix_seconds(1_737_655_200)])
86+
}
87+
88+
pub fn us_daylight_start_test() {
89+
let db = get_database()
90+
let dst_start_date = calendar.Date(2025, calendar.March, 9)
91+
92+
let one = calendar.TimeOfDay(1, 0, 0, 0)
93+
let one_thirty = calendar.TimeOfDay(1, 30, 0, 0)
94+
let two = calendar.TimeOfDay(2, 0, 0, 0)
95+
let two_thirty = calendar.TimeOfDay(2, 30, 0, 0)
96+
let three = calendar.TimeOfDay(3, 0, 0, 0)
97+
let three_thirty = calendar.TimeOfDay(3, 30, 0, 0)
98+
99+
assert tzcalendar.from_calendar(dst_start_date, one, "America/New_York", db)
100+
== Ok([timestamp.from_unix_seconds(1_741_500_000)])
101+
assert tzcalendar.from_calendar(
102+
dst_start_date,
103+
one_thirty,
104+
"America/New_York",
105+
db,
106+
)
107+
== Ok([timestamp.from_unix_seconds(1_741_501_800)])
108+
assert tzcalendar.from_calendar(dst_start_date, two, "America/New_York", db)
109+
== Ok([])
110+
assert tzcalendar.from_calendar(
111+
dst_start_date,
112+
two_thirty,
113+
"America/New_York",
114+
db,
115+
)
116+
== Ok([])
117+
assert tzcalendar.from_calendar(dst_start_date, three, "America/New_York", db)
118+
== Ok([timestamp.from_unix_seconds(1_741_503_600)])
119+
assert tzcalendar.from_calendar(
120+
dst_start_date,
121+
three_thirty,
122+
"America/New_York",
123+
db,
124+
)
125+
== Ok([timestamp.from_unix_seconds(1_741_505_400)])
126+
}
127+
128+
pub fn us_daylight_stop_test() {
129+
let db = get_database()
130+
let dst_start_date = calendar.Date(2025, calendar.November, 2)
131+
132+
let one = calendar.TimeOfDay(1, 0, 0, 0)
133+
let one_thirty = calendar.TimeOfDay(1, 30, 0, 0)
134+
let two = calendar.TimeOfDay(2, 0, 0, 0)
135+
let two_thirty = calendar.TimeOfDay(2, 30, 0, 0)
136+
let three = calendar.TimeOfDay(3, 0, 0, 0)
137+
let three_thirty = calendar.TimeOfDay(3, 30, 0, 0)
138+
139+
assert tzcalendar.from_calendar(dst_start_date, one, "America/New_York", db)
140+
== Ok([
141+
timestamp.from_unix_seconds(1_762_059_600),
142+
timestamp.from_unix_seconds(1_762_063_200),
143+
])
144+
assert tzcalendar.from_calendar(
145+
dst_start_date,
146+
one_thirty,
147+
"America/New_York",
148+
db,
149+
)
150+
== Ok([
151+
timestamp.from_unix_seconds(1_762_061_400),
152+
timestamp.from_unix_seconds(1_762_065_000),
153+
])
154+
assert tzcalendar.from_calendar(dst_start_date, two, "America/New_York", db)
155+
== Ok([timestamp.from_unix_seconds(1_762_066_800)])
156+
assert tzcalendar.from_calendar(
157+
dst_start_date,
158+
two_thirty,
159+
"America/New_York",
160+
db,
161+
)
162+
== Ok([timestamp.from_unix_seconds(1_762_068_600)])
163+
assert tzcalendar.from_calendar(dst_start_date, three, "America/New_York", db)
164+
== Ok([timestamp.from_unix_seconds(1_762_070_400)])
165+
assert tzcalendar.from_calendar(
166+
dst_start_date,
167+
three_thirty,
168+
"America/New_York",
169+
db,
170+
)
171+
== Ok([timestamp.from_unix_seconds(1_762_072_200)])
172+
}
173+
174+
pub fn au_daylight_start_test() {
175+
let db = get_database()
176+
let dst_start_date = calendar.Date(2025, calendar.October, 5)
177+
178+
let one = calendar.TimeOfDay(1, 0, 0, 0)
179+
let one_thirty = calendar.TimeOfDay(1, 30, 0, 0)
180+
let two = calendar.TimeOfDay(2, 0, 0, 0)
181+
let two_thirty = calendar.TimeOfDay(2, 30, 0, 0)
182+
let three = calendar.TimeOfDay(3, 0, 0, 0)
183+
let three_thirty = calendar.TimeOfDay(3, 30, 0, 0)
184+
185+
assert tzcalendar.from_calendar(dst_start_date, one, "Australia/Canberra", db)
186+
== Ok([timestamp.from_unix_seconds(1_759_590_000)])
187+
assert tzcalendar.from_calendar(
188+
dst_start_date,
189+
one_thirty,
190+
"Australia/Canberra",
191+
db,
192+
)
193+
== Ok([timestamp.from_unix_seconds(1_759_591_800)])
194+
assert tzcalendar.from_calendar(dst_start_date, two, "Australia/Canberra", db)
195+
== Ok([])
196+
assert tzcalendar.from_calendar(
197+
dst_start_date,
198+
two_thirty,
199+
"Australia/Canberra",
200+
db,
201+
)
202+
== Ok([])
203+
assert tzcalendar.from_calendar(
204+
dst_start_date,
205+
three,
206+
"Australia/Canberra",
207+
db,
208+
)
209+
== Ok([timestamp.from_unix_seconds(1_759_593_600)])
210+
assert tzcalendar.from_calendar(
211+
dst_start_date,
212+
three_thirty,
213+
"Australia/Canberra",
214+
db,
215+
)
216+
== Ok([timestamp.from_unix_seconds(1_759_595_400)])
217+
}
218+
219+
pub fn au_daylight_stop_test() {
220+
let db = get_database()
221+
let dst_start_date = calendar.Date(2025, calendar.April, 6)
222+
223+
let one = calendar.TimeOfDay(1, 0, 0, 0)
224+
let one_thirty = calendar.TimeOfDay(1, 30, 0, 0)
225+
let two = calendar.TimeOfDay(2, 0, 0, 0)
226+
let two_thirty = calendar.TimeOfDay(2, 30, 0, 0)
227+
let three = calendar.TimeOfDay(3, 0, 0, 0)
228+
let three_thirty = calendar.TimeOfDay(3, 30, 0, 0)
229+
230+
assert tzcalendar.from_calendar(dst_start_date, one, "Australia/Canberra", db)
231+
== Ok([timestamp.from_unix_seconds(1_743_861_600)])
232+
assert tzcalendar.from_calendar(
233+
dst_start_date,
234+
one_thirty,
235+
"Australia/Canberra",
236+
db,
237+
)
238+
== Ok([timestamp.from_unix_seconds(1_743_863_400)])
239+
assert tzcalendar.from_calendar(dst_start_date, two, "Australia/Canberra", db)
240+
== Ok([
241+
timestamp.from_unix_seconds(1_743_865_200),
242+
timestamp.from_unix_seconds(1_743_868_800),
243+
])
244+
assert tzcalendar.from_calendar(
245+
dst_start_date,
246+
two_thirty,
247+
"Australia/Canberra",
248+
db,
249+
)
250+
== Ok([
251+
timestamp.from_unix_seconds(1_743_867_000),
252+
timestamp.from_unix_seconds(1_743_870_600),
253+
])
254+
assert tzcalendar.from_calendar(
255+
dst_start_date,
256+
three,
257+
"Australia/Canberra",
258+
db,
259+
)
260+
== Ok([timestamp.from_unix_seconds(1_743_872_400)])
261+
assert tzcalendar.from_calendar(
262+
dst_start_date,
263+
three_thirty,
264+
"Australia/Canberra",
265+
db,
266+
)
267+
== Ok([timestamp.from_unix_seconds(1_743_874_200)])
268+
}

0 commit comments

Comments
 (0)