Subscribe to access all episodes. View plans →

#166: Timezones with TzWorld

Published July 5, 2023

Episode Sponsored by Hire For Elixir


TzWorld

Follow along with the episode starter on GitHub


Here we have the start of an app that we want to use to look up a timezone based on a latitude and longitude.

We’ll need to add a form to the page here so people can put their latitude and longitude, but once those have been submitted, we’ll then need a way to use those values to return the correct timezone.

To help us with the timezone lookup, we’ll use the TzWorld package. TzWorld resolves timezones from a location using data from the timezone-boundary-builder project. To use this in our project, we’ll go to Hex and grab the tz_world config and add it to our application’s Mixfile.

mix.exs

...

defp deps do
  [
    ...
    {:tz_world, "~> 1.3"},
    ...
  ]
end

...

Then we’ll go to the command line and install it with mix deps.get.

$ mix deps.get
...
New:
  geo 3.5.1
  tz_world 1.3.0

Now we need to do some simple configuration. In the docs, there is some optional configuration we can set up, including specifying the directory for the Geo data. We’ll stick with the defaults for our application.

We won’t be able to look up any timezones until we download the timezone Geo data, which we can do with the mix tz_world.update command. As outlined in the docs there is an --include-oceans option to include timezone data for oceans if you need it. Let’s head back to the command line and run  mix tz_world.update to install the latest timezone data. This can take a few seconds while it runs.

$ mix tz_world.update
[info] [TzWorld] No timezone geo data installed. Installing the latest release 2023b.

Great, with that installed, we now just need to choose a strategy to access the backend timezone data. There are several different backend options to choose from, and the docs summarize some of the pros and cons of each option. For our project we’ll use the recommended EtsWithIndexCache. Because each backend is a GenServer, we’ll need to open our application.ex module and add it to our supervision tree.

lib/teacher/application.ex

...
def start(_type, _args) do
  children = [
    ...
    TzWorld.Backend.EtsWithIndexCache
  ]
  ...
end

Now let’s test that our installation worked.

We’ll go to the command line and start an iex session with our application. To look up a timezone, we’ll use TzWorld.timezone_at, which accepts a two-element tuple, with the longitude as the first element and then latitude. When we run it, it returns an :ok tuple with the timezone. If we try it with coordinates that don’t exist, we get an :error tuple back.

$ iex -S mix
> TzWorld.timezone_at({-73.989723, 40.741112})
{:ok, "America/New_York"}
> TzWorld.timezone_at({1.0, 73})
{:error, :time_zone_not_found}

So now that we have TzWorld configured in our application, we’ve confirmed that it’s working; let’s integrate it with a user-facing form to lookup timezones. We’re using LiveView for the page our form is on, so let’s open that corresponding template. Then let’s add the <.form> component. We’ll go ahead and add the :let attribute with a value of f for our form. The for attribute of a @changeset, which we’ll go over after we add the rest of the form. We’ll give it an id, and then let’s set the phx-change and phx-submit form bindings.

Alright, with that, we can add the fields to our form. I’ll go ahead and paste those in, and you can see that we have two fields: one for the latitude and another for the longitude, with our “search” button below.

Template path: lib/teacher_web/live/location_live/index.html.heex

<h1 class="mb-4 text-xl font-semibold">
  Lookup a timezone
</h1>

<.form
  :let={f}
  for={@changeset}
  id="timezone-form"
  phx-change="validate"
  phx-submit="search">
  <div class="grid grid-cols-2">
    <div class="pr-2">
      <%= label f, :latitude, class: "block mb-2" %>
      <%= text_input f, :latitude, class: "border rounded w-full py-2 px-3" %>
      <%= error_tag f, :latitude %>
    </div>
    <div class="pl-2">
      <%= label f, :longitude, class: "block mb-2" %>
      <%= text_input f, :longitude, class: "border rounded w-full py-2 px-3" %>
      <%= error_tag f, :longitude %>
    </div>
  </div>
  <div class="mt-4">
    <%= submit "Search",
      class: "bg-blue-700 w-full rounded text-white py-1 px-4",
      phx_disable_with: "Searching..." %>
  </div>
</.form>

Alright, now that our form is added, let’s tie it into the corresponding live view. The first thing we need to do is add a changeset to the assigns for our form to use, so let’s go to our mount callback. And we want to assign a :changeset value, but we don’t currently have any schema to use for our changeset. We’ll need to create one that we can use.

Let’s create a new directory in “lib/teacher” called “location”. Then we’ll want our new module to represent “latitude” and “longitude” coordinates, so let’s call module coordinates.ex. And instead of using a schema backed by a database table, let’s use a “schemaless changeset.” If you’re not familiar with schemaless changesets, pause this video and watch this episode about schemaless Ecto changesets.

Back in our module, I’ll go ahead and paste in the code for our schemaless changeset. This will behave like any Ecto changeset. It gives us two fields: latitude and longitude, both of which have a type of float. And our module has a changeset function that will cast our fields and validate that both are present.

lib/teacher/location/coordinates.ex

defmodule Teacher.Location.Coordinates do
  import Ecto.Changeset

  defstruct [:latitude, :longitude]

  @types %{
    latitude: :float,
    longitude: :float
  }

  def changeset(attrs \\ %{}) do
    {%__MODULE__{}, @types}
    |> cast(attrs, [:latitude, :longitude])
    |> validate_required([:latitude, :longitude])
  end

end

Great, now that we have our module let’s call it to return our coordinates changeset to use in our form. We need to implement the handle_event callbacks for our phx-change and phx-submit form bindings.

Let’s start with the “validate” event. We’ll pattern match to get the params for our coordinates. Now inside our function, we’ll need to validate our coordinates and return a changeset. We’ll want to do the same here and then when the form is submitted, so let’s extract it into a function we’ll call validate_coordinates that will take our coordinates.

We’ll take those coordinates and pipe them into our Coordinates.changeset function, and then we’ll trigger the validation. Then we can take the returned changeset and return a :noreply tuple with our socket the updated changeset. This should update our form with any validation errors.

We just need to implement the handle_event callback for our “search” event. Then we’ll call validate_coordinates with our coordinates to get our changeset. If our Coordinates changeset were backed by a corresponding database table, we’d probably call Repo.insert to save it to the database. But because our changeset is not meant to be persisted to the database, we can instead call Ecto.Changeset.apply_action to emulate the insert action, passing in the changeset and then specifying the action. In this case :insert. Then we can pattern match on the :ok tuple and then the :error tuple.

If our coordinates are valid, we’ll need a way to fetch their timezone. Let’s create a function called get_timezone that will take our coordinates. Inside it, we can call TzWorld.timezone_at with a tuple of the longitude and latitude from the coordinates. If an :ok tuple is returned, we’ll return the timezone and if there’s an error let’s just return some text “Timezone not found”. Then we can go back to our handle_event callback and call get_timezone and then return a :noreply tuple, assigning the returned timezone to our socket as timezone. If there was an error returned with our changeset, we’ll return a :noreply tuple with the updated changeset.

Now that we’re updating the socket assigns with the returned timezone here. Let’s update our mount callback to assign an initial value of nil for our timezone.

lib/teacher_web/live/location_live/index.ex

defmodule TeacherWeb.LocationLive.Index do
  use TeacherWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
      socket
      |> assign(:timezone, nil)
      |> assign(:changeset, Teacher.Location.Coordinates.changeset())}
  end

  @impl true
  def handle_params(_params, _, socket) do
    {:noreply,
      socket
      |> assign(:page_title, "Find your timezone")}
  end

  @impl true
  def handle_event("search", %{"coordinates" => coordinates_params}, socket) do
    changeset = validate_coordinates(coordinates_params)

    case Ecto.Changeset.apply_action(changeset, :insert) do
      {:ok, coordinates} ->
        timezone = get_timezone(coordinates)
        {:noreply, assign(socket, :timezone, timezone)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, :changeset, changeset)}

    end
  end

  def handle_event("validate", %{"coordinates" => coordinates_params}, socket) do
    changeset = validate_coordinates(coordinates_params)
    {:noreply, assign(socket, :changeset, changeset)}
  end

  defp validate_coordinates(coordinates_attrs) do
    coordinates_attrs
    |> Teacher.Location.Coordinates.changeset()
    |> Map.put(:action, :validate)
  end

  defp get_timezone(coordinates) do
    case TzWorld.timezone_at({coordinates.longitude, coordinates.latitude}) do
      {:ok, timezone} ->
        timezone

      {:error, _} ->
        "Timezone not found"

    end
  end
  
end

Great, now we just need a way to display our timezone on the page if it’s present. Let’s go back to the “location_live” index.html.heex template, and we do have one typo here. form should be for so let’s fix that.

Then let’s go to the bottom of our template, and we’ll conditionally display our @timezone if it exists. I’ll also paste in some css to help with the styling.

Template path: lib/teacher_web/live/location_live/index.html.heex

...
<%= if @timezone do %>
<h3 class="text-xl font-semibold pt-4 text-center"><%= @timezone %></h3>
<% end %>

Great, with that, let’s test out our form.

We’ll go to the command line and start our server.

$ mix phx.server
...

When we go back to the browser, we see our form is displayed. Let’s add a latitude and a longitude. Then let’s test that our validations are working. Perfect - we see our error is displayed. So let’s go ahead and add it back. Then if we submit our form, we should see the “New York” timezone displayed. And great, it worked! Let’s go ahead and try with some more coordinates.

Now let’s test once more with some coordinates that don’t have a timezone. And our “Timezone not found” message is displayed. Our application is now set up to fetch timezones using the TzWorld package.

© 2024 HEXMONSTER LLC