Skip to content

Commit c3901cd

Browse files
committed
Add CLDR metazone data to the supplemental pipeline with metazone_for/2 and zone_for_metazone/2
1 parent 0f7accc commit c3901cd

7 files changed

Lines changed: 7961 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ A quality release: the codebase now passes `mix credo --strict` with zero findin
1818

1919
* `Localize.Territory.territory_names_for/1` and `territories_for/1` return the localized territory-name inventory for a locale, completing the `*_for` family across languages, scripts, subdivisions and territories.
2020

21+
* CLDR metazone data joins the supplemental-data pipeline: `Localize.DateTime.Timezone.metazone_for/2` returns the metazone for an IANA zone at a given instant, and `zone_for_metazone/2` returns a metazone's representative zone per territory. The `z`/`v` format symbols now draw on the full 191-metazone CLDR mapping instead of a 20-zone builtin table.
22+
2123
* A documentation depth pass adds around 150 execution-verified doctest examples and 40 `### Options` sections across the number, unit, date/time, locale, territory, list, collation and message modules, and corrects stale claims (RBNF number-system conversion, locale defaults, the `validate_locale/1` matching warning).
2224

2325
* A test-coverage push adds around 1,700 tests across exceptions, collation, date/time formatting, MessageFormat 2 tooling, language-tag validity, number/unit internals and the runtime plumbing, raising runtime-library line coverage from 70% to 91%. The 90% threshold is now enforced in CI.

data/data.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ defmodule Localize.Data do
5252
"languageMatching.json",
5353
"likelySubtags.json",
5454
"measurementData.json",
55+
"metaZones.json",
5556
"numberingSystems.json",
5657
"ordinals.json",
5758
"parentLocales.json",
@@ -110,6 +111,7 @@ defmodule Localize.Data do
110111
{"territory_currencies.etf", &Localize.Data.Supplemental.generate_territory_currencies/0},
111112
# language_matching must come after territory_containers (it needs container data)
112113
{"language_matching.etf", &Localize.Data.Supplemental.generate_language_matching/0},
114+
{"metazones.etf", &Localize.Data.Supplemental.generate_metazones/0},
113115
{"time_preferences.etf", &Localize.Data.Supplemental.generate_time_preferences/0},
114116
{"weeks.etf", &Localize.Data.Supplemental.generate_weeks/0},
115117
{"plural_rules_cardinal.etf", &Localize.Data.PluralRules.generate_plural_rules_cardinal/0},

data/supplemental.ex

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,85 @@ defmodule Localize.Data.Supplemental do
244244
|> LMap.atomize_keys()
245245
end
246246

247+
@doc """
248+
Generates metazone data from `metaZones.json`.
249+
250+
Returns a map with two keys. `:mapzones` maps each metazone atom
251+
(underscored to match the locale `time_zone_names.metazone` keys)
252+
to a map of territory atoms to IANA zone names, where territory
253+
`:"001"` is the metazone's golden zone. `:metazone_info` maps each
254+
IANA zone name to its chronological list of metazone usage periods,
255+
each a map with `:metazone`, `:from` and `:to` (`NaiveDateTime` UTC
256+
instants, `nil` for an open boundary).
257+
258+
"""
259+
def generate_metazones do
260+
meta_zones =
261+
Localize.Data.read_json("metaZones.json")
262+
|> get_in(["supplemental", "metaZones"])
263+
264+
%{
265+
mapzones: metazone_mapzones(meta_zones),
266+
metazone_info: metazone_info(meta_zones)
267+
}
268+
end
269+
270+
defp metazone_mapzones(meta_zones) do
271+
meta_zones
272+
|> Map.fetch!("metazones")
273+
|> Enum.reduce(%{}, fn %{"mapZone" => map_zone}, acc ->
274+
metazone = metazone_atom(map_zone["_other"])
275+
territory = String.to_atom(map_zone["_territory"])
276+
277+
Map.update(acc, metazone, %{territory => map_zone["_type"]}, fn territories ->
278+
Map.put(territories, territory, map_zone["_type"])
279+
end)
280+
end)
281+
end
282+
283+
defp metazone_info(meta_zones) do
284+
meta_zones
285+
|> get_in(["metazoneInfo", "timezone"])
286+
|> collect_zone_periods([])
287+
|> Map.new()
288+
end
289+
290+
# The metazoneInfo hierarchy nests zone-name segments to varying
291+
# depth ("America" → "Indiana" → "Knox"); a list marks a leaf
292+
# holding that zone's usage periods.
293+
defp collect_zone_periods(node, path) when is_map(node) do
294+
Enum.flat_map(node, fn {segment, child} ->
295+
collect_zone_periods(child, [segment | path])
296+
end)
297+
end
298+
299+
defp collect_zone_periods(periods, path) when is_list(periods) do
300+
zone_name = path |> Enum.reverse() |> Enum.join("/")
301+
302+
usage =
303+
Enum.map(periods, fn %{"usesMetazone" => uses} ->
304+
%{
305+
metazone: metazone_atom(uses["_mzone"]),
306+
from: metazone_instant(uses["_from"]),
307+
to: metazone_instant(uses["_to"])
308+
}
309+
end)
310+
311+
[{zone_name, usage}]
312+
end
313+
314+
defp metazone_atom(name) do
315+
name
316+
|> Localize.Utils.String.underscore()
317+
|> String.to_atom()
318+
end
319+
320+
defp metazone_instant(nil), do: nil
321+
322+
defp metazone_instant(string) do
323+
NaiveDateTime.from_iso8601!(string <> ":00")
324+
end
325+
247326
@doc """
248327
Generates sorted list of currency code atoms from `currencyData.json`.
249328

lib/localize/datetime/timezone.ex

Lines changed: 138 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ defmodule Localize.DateTime.Timezone do
1616
@timezones_by_territory Builder.timezones_by_territory(@timezones)
1717
@territories_by_timezone Builder.territories_by_timezone(@timezones_by_territory)
1818

19+
@metazone_data SupplementalData.metazones()
20+
@metazone_mapzones @metazone_data.mapzones
21+
@metazone_info @metazone_data.metazone_info
22+
23+
# CLDR metazone data keys zones by their canonical IANA name; the
24+
# first alias of a BCP 47 timezone entry is that canonical name,
25+
# so map every alias (including the canonical name itself) to it.
26+
@zone_canonical_names for {_bcp47, %{aliases: aliases}} <- @timezones,
27+
is_list(aliases) and aliases != [],
28+
canonical = hd(aliases),
29+
alias_name <- aliases,
30+
into: %{},
31+
do: {alias_name, canonical}
32+
1933
# ── Timezone Data Access ─────────────────────────────────────
2034

2135
@doc """
@@ -267,31 +281,129 @@ defmodule Localize.DateTime.Timezone do
267281
# X (1-5) - ISO8601 with Z for zero offset
268282
# x (1-5) - ISO8601 without Z for zero offset
269283

270-
# Mapping from IANA timezone to CLDR metazone
271-
# This is a simplified mapping covering the most common zones.
272-
# A full implementation would load this from CLDR supplemental data.
273-
@zone_to_metazone %{
274-
"America/New_York" => :america_eastern,
275-
"America/Chicago" => :america_central,
276-
"America/Denver" => :america_mountain,
277-
"America/Los_Angeles" => :america_pacific,
278-
"America/Anchorage" => :alaska,
279-
"Pacific/Honolulu" => :hawaii_aleutian,
280-
"Europe/London" => :gmt,
281-
"Europe/Paris" => :europe_central,
282-
"Europe/Berlin" => :europe_central,
283-
"Europe/Moscow" => :moscow,
284-
"Asia/Tokyo" => :japan,
285-
"Asia/Shanghai" => :china,
286-
"Asia/Kolkata" => :india,
287-
"Asia/Dubai" => :gulf,
288-
"Australia/Sydney" => :australia_eastern,
289-
"Australia/Melbourne" => :australia_eastern,
290-
"Australia/Perth" => :australia_western,
291-
"Etc/UTC" => :gmt,
292-
"Etc/GMT" => :gmt,
293-
"UTC" => :gmt
294-
}
284+
@doc """
285+
Returns the CLDR metazone for an IANA timezone name.
286+
287+
Zones move between metazones over time (for example
288+
`America/Indiana/Knox` has alternated between the central and
289+
eastern metazones), so the datetime selects the applicable usage
290+
period.
291+
292+
### Arguments
293+
294+
* `time_zone` is an IANA timezone name (e.g., `"America/New_York"`)
295+
or any of its CLDR aliases (e.g., `"Asia/Calcutta"`).
296+
297+
* `datetime` is a map that may carry `:year` .. `:second` fields
298+
selecting the metazone in effect at that instant. When the fields
299+
are absent (or `datetime` is `nil`), the currently effective
300+
metazone is returned. The default is `nil`.
301+
302+
### Returns
303+
304+
* The metazone as an atom (e.g., `:america_eastern`), matching the
305+
keys of the locale `time_zone_names.metazone` data.
306+
307+
* `nil` when the zone has no metazone mapping for the instant.
308+
309+
### Examples
310+
311+
iex> Localize.DateTime.Timezone.metazone_for("America/New_York")
312+
:america_eastern
313+
314+
iex> Localize.DateTime.Timezone.metazone_for("Asia/Calcutta")
315+
:india
316+
317+
iex> Localize.DateTime.Timezone.metazone_for("America/Indiana/Knox", ~N[2000-06-01 00:00:00])
318+
:america_eastern
319+
320+
iex> Localize.DateTime.Timezone.metazone_for("America/Indiana/Knox", ~N[2020-06-01 00:00:00])
321+
:america_central
322+
323+
"""
324+
@spec metazone_for(String.t(), map() | nil) :: atom() | nil
325+
def metazone_for(time_zone, datetime \\ nil) do
326+
canonical = Map.get(@zone_canonical_names, time_zone, time_zone)
327+
328+
# CLDR assigns no metazone to Etc/UTC, but its conformance data
329+
# expects the GMT metazone names for it ("Greenwich Mean Time").
330+
canonical = if canonical == "Etc/UTC", do: "Etc/GMT", else: canonical
331+
332+
periods = Map.get(@metazone_info, canonical, [])
333+
instant = metazone_instant(datetime)
334+
335+
Enum.find_value(periods, fn %{metazone: metazone, from: from, to: to} ->
336+
if within_period?(instant, from, to), do: metazone
337+
end)
338+
end
339+
340+
@doc """
341+
Returns the IANA timezone that represents a CLDR metazone.
342+
343+
### Arguments
344+
345+
* `metazone` is a metazone atom as returned by `metazone_for/2`
346+
(e.g., `:america_pacific`).
347+
348+
* `territory` is a territory atom used to select a
349+
territory-specific representative zone (e.g., `:CA` selects
350+
`"America/Vancouver"` for `:america_pacific`). The default is
351+
`:"001"`, the metazone's golden zone.
352+
353+
### Returns
354+
355+
* The IANA timezone name for the territory, falling back to the
356+
metazone's golden zone when the territory has no specific
357+
mapping.
358+
359+
* `nil` when the metazone is unknown.
360+
361+
### Examples
362+
363+
iex> Localize.DateTime.Timezone.zone_for_metazone(:america_pacific)
364+
"America/Los_Angeles"
365+
366+
iex> Localize.DateTime.Timezone.zone_for_metazone(:america_pacific, :CA)
367+
"America/Vancouver"
368+
369+
iex> Localize.DateTime.Timezone.zone_for_metazone(:no_such_metazone)
370+
nil
371+
372+
"""
373+
@spec zone_for_metazone(atom(), atom()) :: String.t() | nil
374+
def zone_for_metazone(metazone, territory \\ :"001") do
375+
case Map.get(@metazone_mapzones, metazone) do
376+
nil -> nil
377+
territories -> Map.get(territories, territory) || Map.get(territories, :"001")
378+
end
379+
end
380+
381+
# A metazone usage period is selected by a UTC instant; when the
382+
# datetime carries no date fields (or is nil) the open-ended
383+
# current period matches via the nil instant.
384+
defp metazone_instant(%{year: year} = datetime) when is_integer(year) do
385+
{:ok, instant} =
386+
NaiveDateTime.new(
387+
year,
388+
Map.get(datetime, :month, 1),
389+
Map.get(datetime, :day, 1),
390+
Map.get(datetime, :hour, 0),
391+
Map.get(datetime, :minute, 0),
392+
Map.get(datetime, :second, 0)
393+
)
394+
395+
offset = Map.get(datetime, :utc_offset, 0) + Map.get(datetime, :std_offset, 0)
396+
NaiveDateTime.add(instant, -offset, :second)
397+
end
398+
399+
defp metazone_instant(_datetime), do: nil
400+
401+
defp within_period?(nil, _from, to), do: is_nil(to)
402+
403+
defp within_period?(instant, from, to) do
404+
(is_nil(from) or NaiveDateTime.compare(instant, from) != :lt) and
405+
(is_nil(to) or NaiveDateTime.compare(instant, to) == :lt)
406+
end
295407

296408
@doc """
297409
Returns the specific or generic non-location timezone name.
@@ -351,7 +463,7 @@ defmodule Localize.DateTime.Timezone do
351463

352464
with {:ok, tz_data} <- Localize.Locale.get(locale_id, [:dates, :time_zone_names]) do
353465
# Try metazone lookup first
354-
metazone_key = Map.get(@zone_to_metazone, time_zone)
466+
metazone_key = metazone_for(time_zone, datetime)
355467
result = metazone_name(metazone_key, tz_data, format, type, datetime)
356468

357469
if result do

lib/localize/supplemental_data.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ defmodule Localize.SupplementalData do
107107
load_supplemental("timezones.etf")
108108
end
109109

110+
# Metazone data with :mapzones (metazone → territory → IANA zone)
111+
# and :metazone_info (IANA zone → metazone usage periods).
112+
@doc false
113+
@spec metazones() :: %{mapzones: map(), metazone_info: map()}
114+
def metazones do
115+
load_supplemental("metazones.etf")
116+
end
117+
110118
@doc false
111119
@spec unicode_script_to_subtag_mapping() :: map()
112120
def unicode_script_to_subtag_mapping do

0 commit comments

Comments
 (0)