From 10d584adffb0dd8fe3a8cb6fa72bdea5a88091a9 Mon Sep 17 00:00:00 2001 From: Kamil Kowalski Date: Fri, 31 Jul 2026 10:51:03 +0200 Subject: [PATCH] Fix Morocco reported as UTC six months early in tzdata 2026c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background on the data: the IANA time zone database describes each zone as a stack of "zone lines". Each line covers a span of history — it gives the base offset and which daylight-saving rules apply — and ends on a date when the next line takes over. Importantly, the daylight-saving rules a line points at can stop firing before the line's own end date. In tzdata 2026c, Morocco switches to permanent UTC on 20 September 2026. The zone line for Africa/Casablanca and Africa/El_Aaiun applies a +01 base offset with Morocco's daylight-saving rules and runs until that September switch. But those rules stop in March 2026, after the year's final Ramadan adjustment — so the line has six months left to run with no more rules. PeriodBuilder assumed a zone line ended the moment its rules stopped, so it switched Morocco to permanent UTC in March instead of September. That dropped the +01 span from March to September: any instant in that window was reported as UTC — one hour off — for roughly six months. The fix: when a zone line's rules run out but the line itself keeps going, hold the offset the last rule left in effect until the line's real end date, then move on to the next line. This only kicks in for lines with a fixed end date, so zones whose final line runs forever (and hand off to dynamically computed future periods) are unaffected. The same mistake also affected historical transitions in a number of other zones. Verified against zdump: 78 previously-wrong transitions now match, with no regressions. --- CHANGELOG.md | 6 +++ lib/tzdata/period_builder.ex | 80 ++++++++++++++++++++---------- test/tz_period_builder_test.exs | 72 +++++++++++++++++++++++++++ test/tzdata_fixtures/morocco_2026c | 74 +++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 test/tzdata_fixtures/morocco_2026c 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