Skip to content

Commit

Permalink
Add CountryTimeZone module
Browse files Browse the repository at this point in the history
  • Loading branch information
mathieuprog committed Jul 21, 2024
1 parent 7efbafb commit 64b3dac
Show file tree
Hide file tree
Showing 8 changed files with 185 additions and 131 deletions.
35 changes: 13 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

`tz_extra` provides a few utilities to work with time zones. It uses [`Tz`](https://github.com/mathieuprog/tz) under the hood, which brings time zone support for Elixir.

* [`TzExtra.countries_time_zones/1`](#tzextracountries_time_zones1): returns a list of time zone data by country
* [`TzExtra.countries_time_zones/0`](#tzextracountries_time_zones0): returns a list of time zone data by country
* [`TzExtra.CountryTimeZone.for_country_code/1`](#tzextracountrytimezonefor_country_code1): returns a list of time zone data for a given country
* [`TzExtra.CountryTimeZone.for_time_zone/1`](#tzextracountrytimezonefor_time_zone1): returns a list of time zone data for a time zone
* [`TzExtra.time_zone_identifiers/1`](#tzextratime_zone_identifiers1): returns a list of time zone identifiers
* [`TzExtra.civil_time_zone_identifiers/1`](#tzextracivil_time_zone_identifiers1): returns a list of time zone identifiers that are tied to a country
* [`TzExtra.countries/0`](#tzextracountries0): returns a list of ISO country codes with their English name
Expand All @@ -11,7 +13,7 @@
* [`TzExtra.Changeset.validate_civil_time_zone_identifier/3`](#tzextraChangesetvalidate_civil_time_zone_identifier3): an Ecto Changeset validator, validating that the user input is a valid civil time zone
* [`TzExtra.Changeset.validate_iso_country_code/3`](#tzextraChangesetvalidate_iso_country_code3): an Ecto Changeset validator, validating that the user input is a valid ISO country code

### `TzExtra.countries_time_zones/1`
### `TzExtra.countries_time_zones/0`

Returns a list of time zone data by country. The data includes:
* the country and time zone;
Expand Down Expand Up @@ -48,22 +50,13 @@ iex> TzExtra.countries_time_zones() |> Enum.at(5)
Note that a time zone may be observed by multiple countries. For example, the tz database version `2019c` lists 10
countries observing the time zone `Africa/Lagos`; this will result in 10 map entries for that time zone.

You may pass the `:prepend_utc` option set to `true`, in order to add the UTC time zone to the list; the following map is then added:
### `TzExtra.CountryTimeZone.for_country_code/1`

```elixir
%{
coordinates: nil,
country: nil,
dst_offset: 0,
dst_zone_abbr: "UTC",
pretty_dst_offset: "+00:00",
pretty_utc_offset: "+00:00",
time_zone: "UTC",
time_zone_links: [],
utc_offset: 0,
zone_abbr: "UTC"
}
```
Returns a list of time zone data for the given country code (string or atom).

### `TzExtra.CountryTimeZone.for_time_zone/1`

Returns a list of time zone data for the given time zone.

### `TzExtra.time_zone_identifiers/1`

Expand All @@ -81,7 +74,7 @@ iex> TzExtra.time_zone_identifiers() |> Enum.take(5)
]
```

This function can take an option `:include_alias` (by default set to `false`). By default, only canonical time zones are returned. Set this option to `true` to include time zone aliases (also called links).
This function can take an option `:include_aliases` (by default set to `false`) to include time zone aliases. By default, only canonical time zones are returned. Set this option to `true` to include time zone aliases (also called links).

### `TzExtra.civil_time_zone_identifiers/1`

Expand All @@ -90,10 +83,8 @@ iex> TzExtra.civil_time_zone_identifiers()
```

This function returns only the time zone identifiers attached to a country. It takes two options:
* `:include_alias` (by default set to `false`)
* `:include_aliases` (by default set to `false`)
By default, only canonical time zones are returned. Set this option to `false` to include time zone aliases (also called links).
* `:prepend_utc` (by default set to `false`)
Add the UTC time zone as the first element of the time zone list.

### `TzExtra.countries/0`

Expand Down Expand Up @@ -153,7 +144,7 @@ changeset
|> validate_civil_time_zone_identifier(:time_zone)
```

You may pass the options `:allow_alias` and `:allow_utc` to allow time zone aliases and the UTC time zone, as well as the `:message` option to customize the error message.
You may pass the option `:allow_alias` to allow time zone aliases, as well as the `:message` option to customize the error message.

### `TzExtra.Changeset.validate_iso_country_code/3`

Expand Down
43 changes: 28 additions & 15 deletions lib/changeset.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,50 @@ if Code.ensure_loaded?(Ecto.Changeset) do
defmodule TzExtra.Changeset do
require TzExtra.Compiler

def validate_time_zone_identifier(%Ecto.Changeset{} = changeset, field, opts \\ []) when is_atom(field) do
def validate_time_zone_identifier(%Ecto.Changeset{} = changeset, field, opts \\ [])
when is_atom(field) do
opts =
opts
|> Keyword.put(:include_alias, Keyword.get(opts, :allow_alias, false))
|> Keyword.put(:include_aliases, Keyword.get(opts, :allow_alias, false))

Ecto.Changeset.validate_change changeset, field, :time_zone_identifier, fn _field, value ->
Ecto.Changeset.validate_change(changeset, field, :time_zone_identifier, fn _field, value ->
if Enum.member?(TzExtra.time_zone_identifiers(opts), value),
do: [],
else: [{field, {message(opts, "is not a valid time zone"), [validation: :time_zone_identifier]}}]
end
else: [
{field,
{message(opts, "is not a valid time zone"), [validation: :time_zone_identifier]}}
]
end)
end

def validate_civil_time_zone_identifier(%Ecto.Changeset{} = changeset, field, opts \\ []) when is_atom(field) do
def validate_civil_time_zone_identifier(%Ecto.Changeset{} = changeset, field, opts \\ [])
when is_atom(field) do
opts =
opts
|> Keyword.put(:include_alias, Keyword.get(opts, :allow_alias, false))
|> Keyword.put(:prepend_utc, Keyword.get(opts, :allow_utc, false))
|> Keyword.put(:include_aliases, Keyword.get(opts, :allow_alias, false))

Ecto.Changeset.validate_change changeset, field, :civil_time_zone_identifier, fn _field, value ->
Ecto.Changeset.validate_change(changeset, field, :civil_time_zone_identifier, fn _field,
value ->
if Enum.member?(TzExtra.civil_time_zone_identifiers(opts), value),
do: [],
else: [{field, {message(opts, "is not a valid time zone"), [validation: :civil_time_zone_identifier]}}]
end
else: [
{field,
{message(opts, "is not a valid time zone"),
[validation: :civil_time_zone_identifier]}}
]
end)
end

def validate_iso_country_code(%Ecto.Changeset{} = changeset, field, opts \\ []) when is_atom(field) do
Ecto.Changeset.validate_change changeset, field, :iso_country_code, fn _field, value ->
def validate_iso_country_code(%Ecto.Changeset{} = changeset, field, opts \\ [])
when is_atom(field) do
Ecto.Changeset.validate_change(changeset, field, :iso_country_code, fn _field, value ->
if Enum.any?(TzExtra.countries(), fn %{code: country_code} -> country_code == value end),
do: [],
else: [{field, {message(opts, "is not a valid country code"), [validation: :iso_country_code]}}]
end
else: [
{field,
{message(opts, "is not a valid country code"), [validation: :iso_country_code]}}
]
end)
end

defp message(opts, key \\ :message, default) do
Expand Down
142 changes: 90 additions & 52 deletions lib/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,10 @@ defmodule TzExtra.Compiler do

alias TzExtra.IanaFileParser

@utc_time_zone_id "Etc/UTC"

def compile() do
countries = IanaFileParser.countries()
time_zones = IanaFileParser.time_zones()

get_time_zone_links_for_canonical_fun =
fn canonical ->
time_zones[canonical]
end

countries_time_zones =
IanaFileParser.time_zones_with_country(countries)
|> add_time_zone_links(get_time_zone_links_for_canonical_fun)
|> add_offset_data()
|> Enum.sort_by(&{&1.country && normalize_string(&1.country.name), &1.utc_offset, &1.time_zone})

[utc_data] = add_offset_data([%{coordinates: nil, country: nil, time_zone: @utc_time_zone_id, time_zone_links: []}])

countries_time_zones_with_utc = [utc_data | countries_time_zones]

canonical_time_zones =
time_zones
|> Map.keys()
Expand All @@ -39,29 +22,94 @@ defmodule TzExtra.Compiler do
|> List.flatten()
|> Enum.uniq()
|> Enum.sort()
|> Enum.sort()

link_canonical_map =
time_zones
|> Enum.reduce(%{}, fn {canonical, links}, map ->
Enum.reduce(links, map, fn link, map ->
Map.put(map, link, canonical)
end)
|> Map.put(canonical, canonical)
end)

get_time_zone_links_for_canonical_fun =
fn canonical ->
time_zones[canonical]
end

countries_time_zones =
IanaFileParser.time_zones_with_country(countries)
|> add_time_zone_links(get_time_zone_links_for_canonical_fun)
|> add_offset_data()
|> Enum.sort_by(
&{&1.country && normalize_string(&1.country.name), &1.utc_offset, &1.time_zone}
)

civil_time_zones =
countries_time_zones
|> Enum.map(&(&1.time_zone))
|> Enum.map(& &1.time_zone)
|> Enum.uniq()
|> Enum.sort()

civil_time_zones_with_links =
countries_time_zones
|> Enum.map(&([&1.time_zone | &1.time_zone_links]))
|> Enum.map(&[&1.time_zone | &1.time_zone_links])
|> List.flatten()
|> Enum.uniq()
|> Enum.sort()

alias_canonical_map =
time_zones
|> Enum.reduce(%{}, fn {canonical, links}, map ->
Enum.reduce(links, map, fn link, map ->
Map.put(map, link, canonical)
end)
|> Map.put(canonical, canonical)
end)
quoted = [
for time_zone <- all_time_zones do
canonical_time_zone = link_canonical_map[time_zone]

countries_time_zones =
Enum.filter(countries_time_zones, &(&1.time_zone == canonical_time_zone))

if length(countries_time_zones) > 0 do
quote do
def for_time_zone(unquote(time_zone)) do
{:ok, unquote(Macro.escape(countries_time_zones))}
end
end
else
quote do
def for_time_zone(unquote(time_zone)) do
{:error, :time_zone_not_linked_to_country}
end
end
end
end,
quote do
def for_time_zone(_) do
{:error, :time_zone_not_found}
end
end,
for %{code: country_code} <- countries do
countries_time_zones =
Enum.filter(countries_time_zones, &(&1.country.code == country_code))

country_code_atom = String.to_atom(country_code)

quote do
def for_country_code(unquote(country_code)) do
{:ok, unquote(Macro.escape(countries_time_zones))}
end

def for_country_code(unquote(country_code_atom)) do
{:ok, unquote(Macro.escape(countries_time_zones))}
end
end
end,
quote do
def for_country_code(_) do
{:error, :country_not_found}
end
end
]

module = :"Elixir.TzExtra.CountryTimeZone"
Module.create(module, quoted, Macro.Env.location(__ENV__))
:code.purge(module)

contents = [
quote do
Expand All @@ -70,46 +118,32 @@ defmodule TzExtra.Compiler do
end

def get_canonical_time_zone_identifier(time_zone_identifier) do
unquote(Macro.escape(alias_canonical_map))[time_zone_identifier] ||
unquote(Macro.escape(link_canonical_map))[time_zone_identifier] ||
raise "time zone identifier \"#{time_zone_identifier}\" not found"
end

def civil_time_zone_identifiers(opts \\ []) do
prepend_utc = Keyword.get(opts, :prepend_utc, false)
include_alias = Keyword.get(opts, :include_alias, false)

time_zones =
if include_alias do
unquote(Macro.escape(civil_time_zones_with_links))
else
unquote(Macro.escape(civil_time_zones))
end
include_aliases = Keyword.get(opts, :include_aliases, false)

if prepend_utc do
[unquote(@utc_time_zone_id) | time_zones]
if include_aliases do
unquote(Macro.escape(civil_time_zones_with_links))
else
time_zones
unquote(Macro.escape(civil_time_zones))
end
end

def time_zone_identifiers(opts \\ []) do
include_alias = Keyword.get(opts, :include_alias, false)
include_aliases = Keyword.get(opts, :include_aliases, false)

if include_alias do
if include_aliases do
unquote(Macro.escape(all_time_zones))
else
unquote(Macro.escape(canonical_time_zones))
end
end

def countries_time_zones(opts \\ []) do
prepend_utc = Keyword.get(opts, :prepend_utc, false)

if prepend_utc do
unquote(Macro.escape(countries_time_zones_with_utc))
else
unquote(Macro.escape(countries_time_zones))
end
def countries_time_zones() do
unquote(Macro.escape(countries_time_zones))
end

def countries() do
Expand Down Expand Up @@ -174,7 +208,11 @@ defmodule TzExtra.Compiler do

defp add_time_zone_links(countries_time_zones, get_time_zone_links_for_canonical_fun) do
Enum.map(countries_time_zones, fn %{time_zone: time_zone_id} = time_zone ->
Map.put(time_zone, :time_zone_links, get_time_zone_links_for_canonical_fun.(time_zone_id))
Map.put(
time_zone,
:time_zone_links,
get_time_zone_links_for_canonical_fun.(time_zone_id)
)
end)
end
end
4 changes: 2 additions & 2 deletions lib/json_dumper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ if Code.ensure_loaded?(Jason) do
File.write!(file_path, json, [:write])
end

def dump_countries_time_zones(options \\ [], filename \\ "countries_time_zones.json") do
def dump_countries_time_zones(filename \\ "countries_time_zones.json") do
json =
TzExtra.countries_time_zones(options)
TzExtra.countries_time_zones()
|> Enum.map(&camelize_map_keys(&1))
|> Jason.encode!()

Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ defmodule TzExtra.MixProject do
defp deps do
[
{:tz, "~> 0.26"},
{:ecto, "~> 3.9", optional: true},
{:ecto, "~> 3.11", optional: true},
{:jason, "~> 1.4", only: :dev},
{:ex_doc, "~> 0.29", only: :dev}
{:ex_doc, "~> 0.34", only: :dev}
]
end

Expand Down
Loading

0 comments on commit 64b3dac

Please sign in to comment.