Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog for Tzdata

### Fixed

- Fix Africa/Casablanca and Africa/El_Aaiun switching to permanent UTC six
months early in tzdata 2026c, and similar errors in some historical
transitions, caused by a zone line whose rules end before the line does.

### Changed

- Now supports version 4.x of hackney as well as 1.x
Expand Down
80 changes: 55 additions & 25 deletions lib/tzdata/period_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -311,40 +311,70 @@ defmodule Tzdata.PeriodBuilder do
no_more_rules = rules_tail == []
no_more_years = tl(years) == []

# If we've hit the upper time boundary of this zone line, we do not need to examine any more
# rules for this rule set OR there are no more years to consider for this rule set
if last_included_rule || no_more_years && no_more_rules do
h_calc_next_zone_line(btz_data, period, until_utc, zone_line_tl, letter)
else
tail = cond do
# If there are no more rules for the year, continue with the next year
no_more_rules ->
cond do
# If we've hit the upper time boundary of this zone line, we do not need to examine any more
# rules for this rule set.
last_included_rule ->
h_calc_next_zone_line(btz_data, period, until_utc, zone_line_tl, letter)

# There are no more rules or years to consider, but the zone line has an explicit `until`
# that lies after the last rule transition. We still need to emit the remaining span (from
# the last transition up to the zone line's `until`, using the offset the last rule left in
# effect) before moving on to the next zone line. Recursing into calc_rule_periods/8 with an
# empty year list recomputes the zone line's `until` with that offset and handles the hand-off.
# Without this, the final span is dropped and the next zone line starts too early
# (e.g. Africa/Casablanca in tzdata 2026c: Morocco's rules end in March 2026 but the zone
# line runs until 20 September 2026, when the switch to permanent UTC actually happens).
no_more_years && no_more_rules && is_integer(upper_limit) ->
tail =
calc_rule_periods(
btz_data,
[zone_line | zone_line_tl],
until_utc,
utc_off,
rule.save,
years |> tl,
[],
zone_rules,
rule.letter
)
# Else continue with those rules
true ->
calc_periods_for_year(
btz_data,
[zone_line | zone_line_tl],
until_utc,
utc_off,
rule.save,
years,
zone_rules,
rules_tail,
rule.letter,
lower_limit
)
end
if period == nil, do: tail, else: [ period | tail ]

if period == nil, do: tail, else: [period | tail]

# There are no more rules or years and the zone line runs until :max. The current period is
# the last precompiled one; dynamic periods take over beyond this point.
no_more_years && no_more_rules ->
h_calc_next_zone_line(btz_data, period, until_utc, zone_line_tl, letter)

true ->
tail = cond do
# If there are no more rules for the year, continue with the next year
no_more_rules ->
calc_rule_periods(
btz_data,
[zone_line | zone_line_tl],
until_utc,
utc_off,
rule.save,
years |> tl,
zone_rules,
rule.letter
)
# Else continue with those rules
true ->
calc_periods_for_year(
btz_data,
[zone_line | zone_line_tl],
until_utc,
utc_off,
rule.save,
years,
zone_rules,
rules_tail,
rule.letter,
lower_limit
)
end
if period == nil, do: tail, else: [ period | tail ]
end
end

Expand Down
72 changes: 72 additions & 0 deletions test/tz_period_builder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,78 @@ defmodule Tzdata.PeriodBuilderTest do
assert invalid_periods == []
end

describe "zone line whose rules end before the zone line does (tzdata 2026c Morocco)" do
setup do
{:ok, map} = Tzdata.BasicDataMap.from_single_file_in_dir("test/tzdata_fixtures", "morocco_2026c")
{:ok, %{map: map}}
end

# In 2026c, Morocco's DST rules stop in March 2026, but the zone line using them
# runs until 20 September 2026, when Morocco switches to permanent UTC. Previously
# the builder ended the zone line at the last rule transition (March), dropping the
# +01 span from March to September and starting permanent UTC six months too early.
test "keeps the +01 span after the last rule and switches to permanent UTC on 2026-09-20", %{map: map} do
for zone <- ["Africa/Casablanca", "Africa/El_Aaiun"] do
[ramadan, west, permanent] = calc_periods(map, zone) |> Enum.take(-3)

# Ramadan 2026: back to +00 until the last Morocco rule fires
assert ramadan.utc_off + ramadan.std_off == 0
assert ramadan.until.utc == ~G[2026-03-22T02:00:00]

# The previously-dropped span: +01 from the last rule until the zone line ends
assert west == %{
std_off: 0,
utc_off: 3600,
zone_abbr: "+01",
from: %{
utc: ~G[2026-03-22T02:00:00],
standard: ~G[2026-03-22T03:00:00],
wall: ~G[2026-03-22T03:00:00]
},
until: %{
utc: ~G[2026-09-20T01:00:00],
standard: ~G[2026-09-20T02:00:00],
wall: ~G[2026-09-20T02:00:00]
}
}

# Permanent UTC from the zone line's end, forever
assert permanent == %{
std_off: 0,
utc_off: 0,
zone_abbr: "00",
from: %{
utc: ~G[2026-09-20T01:00:00],
standard: ~G[2026-09-20T01:00:00],
wall: ~G[2026-09-20T01:00:00]
},
until: %{utc: :max, standard: :max, wall: :max}
}
end
end

test "reports +01 (not UTC) in the middle of the dropped span", %{map: map} do
# 2026-06-15T12:00:00 UTC falls between the last rule (March) and the switch (September).
instant = ~G[2026-06-15T12:00:00]

for zone <- ["Africa/Casablanca", "Africa/El_Aaiun"] do
period =
calc_periods(map, zone)
|> Enum.find(fn p ->
p.from.utc != :min and p.until.utc != :max and
p.from.utc <= instant and instant < p.until.utc
end)

assert period.utc_off + period.std_off == 3600
end
end

test "produces no overlapping or backwards periods", %{map: map} do
test_for_overlaps(map, "Africa/Casablanca")
test_for_overlaps(map, "Africa/El_Aaiun")
end
end

test "Dublin with negative DST is handled correctly", %{map: map} do
periods = calc_periods(map, "Europe/Dublin")

Expand Down
74 changes: 74 additions & 0 deletions test/tzdata_fixtures/morocco_2026c
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Morocco rules and zones extracted verbatim from IANA tzdata 2026c (africa file).
# Fixture for regression-testing Morocco's switch to permanent UTC on 2026-09-20,
# where the zone line's rules end (2026 Mar) before the zone line itself does (2026 Sep).
# https://lists.iana.org/hyperkitty/list/tz-announce@iana.org/thread/NVHSX2PAQIT44U5FCCEVNJJYXQMMTJSA/

Rule Morocco 1939 only - Sep 12 0:00 1:00 -
Rule Morocco 1939 only - Nov 19 0:00 0 -
Rule Morocco 1940 only - Feb 25 0:00 1:00 -
Rule Morocco 1945 only - Nov 18 0:00 0 -
Rule Morocco 1950 only - Jun 11 0:00 1:00 -
Rule Morocco 1950 only - Oct 29 0:00 0 -
Rule Morocco 1967 only - Jun 3 12:00 1:00 -
Rule Morocco 1967 only - Oct 1 0:00 0 -
Rule Morocco 1974 only - Jun 24 0:00 1:00 -
Rule Morocco 1974 only - Sep 1 0:00 0 -
Rule Morocco 1976 1977 - May 1 0:00 1:00 -
Rule Morocco 1976 only - Aug 1 0:00 0 -
Rule Morocco 1977 only - Sep 28 0:00 0 -
Rule Morocco 1978 only - Jun 1 0:00 1:00 -
Rule Morocco 1978 only - Aug 4 0:00 0 -
Rule Morocco 2008 only - Jun 1 0:00 1:00 -
Rule Morocco 2008 only - Sep 1 0:00 0 -
Rule Morocco 2009 only - Jun 1 0:00 1:00 -
Rule Morocco 2009 only - Aug 21 0:00 0 -
Rule Morocco 2010 only - May 2 0:00 1:00 -
Rule Morocco 2010 only - Aug 8 0:00 0 -
Rule Morocco 2011 only - Apr 3 0:00 1:00 -
Rule Morocco 2011 only - Jul 31 0:00 0 -
Rule Morocco 2012 2013 - Apr lastSun 2:00 1:00 -
Rule Morocco 2012 only - Jul 20 3:00 0 -
Rule Morocco 2012 only - Aug 20 2:00 1:00 -
Rule Morocco 2012 only - Sep 30 3:00 0 -
Rule Morocco 2013 only - Jul 7 3:00 0 -
Rule Morocco 2013 only - Aug 10 2:00 1:00 -
Rule Morocco 2013 2018 - Oct lastSun 3:00 0 -
Rule Morocco 2014 2018 - Mar lastSun 2:00 1:00 -
Rule Morocco 2014 only - Jun 28 3:00 0 -
Rule Morocco 2014 only - Aug 2 2:00 1:00 -
Rule Morocco 2015 only - Jun 14 3:00 0 -
Rule Morocco 2015 only - Jul 19 2:00 1:00 -
Rule Morocco 2016 only - Jun 5 3:00 0 -
Rule Morocco 2016 only - Jul 10 2:00 1:00 -
Rule Morocco 2017 only - May 21 3:00 0 -
Rule Morocco 2017 only - Jul 2 2:00 1:00 -
Rule Morocco 2018 only - May 13 3:00 0 -
Rule Morocco 2018 only - Jun 17 2:00 1:00 -
Rule Morocco 2019 only - May 5 3:00 -1:00 -
Rule Morocco 2019 only - Jun 9 2:00 0 -
Rule Morocco 2020 only - Apr 19 3:00 -1:00 -
Rule Morocco 2020 only - May 31 2:00 0 -
Rule Morocco 2021 only - Apr 11 3:00 -1:00 -
Rule Morocco 2021 only - May 16 2:00 0 -
Rule Morocco 2022 only - Mar 27 3:00 -1:00 -
Rule Morocco 2022 only - May 8 2:00 0 -
Rule Morocco 2023 only - Mar 19 3:00 -1:00 -
Rule Morocco 2023 only - Apr 23 2:00 0 -
Rule Morocco 2024 only - Mar 10 3:00 -1:00 -
Rule Morocco 2024 only - Apr 14 2:00 0 -
Rule Morocco 2025 only - Feb 23 3:00 -1:00 -
Rule Morocco 2025 only - Apr 6 2:00 0 -
Rule Morocco 2026 only - Feb 15 3:00 -1:00 -
Rule Morocco 2026 only - Mar 22 2:00 0 -

Zone Africa/Casablanca -0:30:20 - LMT 1913 Oct 26
0:00 Morocco %z 1984 Mar 16
1:00 - %z 1986
0:00 Morocco %z 2018 Oct 28 3:00
1:00 Morocco %z 2026 Sep 20 2:00
0:00 - %z
Zone Africa/El_Aaiun -0:52:48 - LMT 1934 Jan # El Aaiún
-1:00 - %z 1976 Apr 14
0:00 Morocco %z 2018 Oct 28 3:00
1:00 Morocco %z 2026 Sep 20 2:00
0:00 - %z