diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c76181..e97265e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/tzdata/period_builder.ex b/lib/tzdata/period_builder.ex index bb04696..6b4bb74 100644 --- a/lib/tzdata/period_builder.ex +++ b/lib/tzdata/period_builder.ex @@ -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 diff --git a/test/tz_period_builder_test.exs b/test/tz_period_builder_test.exs index 962e5c2..a96f775 100644 --- a/test/tz_period_builder_test.exs +++ b/test/tz_period_builder_test.exs @@ -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") diff --git a/test/tzdata_fixtures/morocco_2026c b/test/tzdata_fixtures/morocco_2026c new file mode 100644 index 0000000..541bd5e --- /dev/null +++ b/test/tzdata_fixtures/morocco_2026c @@ -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