Subscribe to access all episodes. View plans →
Published July 5, 2023
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.