7 GUIs: Implementing an Interactive Timer in LiveView

This is the fourth post handling the 7 GUI tasks in LiveView. I cover some highlights of implementing an interactive timer — the fourth of the 7 GUI tasks.

The Timer

A timer

These are the task’s requirements:

  • We must have a gauge for the elapsed time.
  • We must have a label that shows the elapsed time as a number.
  • We must have a slider that can change the duration of the timer.
  • Changing the slider should immediately cause the elapsed time gauge to change.
  • When the elapsed time is greater than or equal to the duration (when the gauge is full), the timer should stop. If we then move the slider to increase the duration, the timer should resume.
  • Finally, we should have a reset button that resets the elapsed time to zero.

The Challenge

The most interesting challenges of the task were dealing with the passage of time and rendering and synchronizing the elapsed time gauge and duration slider. So I will focus most on those areas.

Let’s get to it!

Rendering our components

Thanks to HTML, rendering the different components was easy.

To render the gauge, we use a <meter> element. We bind the value to @elapsed_time, and we set the maximum value to @duration. That way, when we change the @duration, our meter is automatically adjusted.

<meter min="0" value="<%= @elapsed_time %>" max="<%= @duration %>"><%= @elapsed_time %></meter>

For the slider, we use an <input type="range">. We keep things simple by allowing it to go from 0-100 in 1 step increments.

<input type="range" name="duration-slider" min="0" max="100" step="1">

Interacting with the slider

Interacting with the slider was the most complex part of the exercise because of the following requirement:

Adjusting S [the slider] must immediately reflect on d [the duration] and not only when S is released. It follows that while moving S the filled amount of G [the gauge] will (usually) change immediately.

In other words, we could not wait until the user drops the slider in its new position to update the gauge. We needed to update values as the slider moved.

Doing a little digging, I learned that the correct event to use is the input event:

The input event is fired every time the value of the element changes. This is unlike the change event, which only fires when the value is committed, such as by pressing the enter key, selecting a value from a list of options, and the like.

Thus, it was clear that we couldn’t use our trusted phx-change attribute. Instead, we wanted something like phx-input, but LiveView doesn’t have a phx-input! 😱

Thankfully, LiveView has great interoperability with JavaScript via hooks. So, I created a Slider hook and attached it via phx-hook="Slider".

-<input type="range" id="duration-slider" name="duration-slider" min="0" max="100" step="1">
+<input phx-hook="Slider" type="range" id="duration-slider" name="duration-slider" min="0" max="100" step="1">

The Slider hook adds an event listener for input events and pushes an “update-duration” event with new value:

let Hooks = {}

Hooks.Slider = {
  mounted() {
    this.el.addEventListener("input", (e) => {
      this.pushEvent("update-duration", {value: e.target.value});
    });
  }
}

The interoperability through Hooks has always been straightforward (yet another fantastic thing about LiveView). Take a look at all the events being sent to the server as we move the slider:

slider hook

To keep things simple (and because things worked well locally), I did not add any debouncing, which we could do in JavaScript or with phx-debounce if we’d used something like phx-change.

Once the events were sent to the back-end, handling them in Elixir was easy. We simply take the value and update :duration assign:

  def handle_event("update-duration", %{"value" => value}, socket) do
    socket
    |> assign(:duration, String.to_integer(value))
    |> noreply()
  end

Dealing with time

When I first read the task’s description, I wondered how difficult it would be to deal with time. Most importantly, how would we stop the timer when the gauge was full?

It seemed to me that dealing with time would be difficult, and it would make code more complex. So, instead of dealing with it first, I decided to separate the UI from the timer running in the background.

To do that, I created a TimerLive.tick/1 function that simulated the passage of time for my LiveView:

  def tick(pid) do
    send(pid, :tick)
  end

It turns out (to no one’s surprise but my own) that the decision to separate the UI from the timer made dealing with time simple.

Our TimerLive doesn’t actually care what does the ticking. It simply increments :elapsed_time by 1 whenever it gets a :tick message.

And that concern about having to stop the timer? Well, it turns out we don’t have to stop the timer at all. We can leave it running. Our UI will simply ignore :tick events when the elapsed time is greater than or equal to the duration:

  def handle_info(:tick, socket) do
    elapsed_time = socket.assigns.elapsed_time
    duration = socket.assigns.duration

    if elapsed_time < duration do
      socket
      |> update(:elapsed_time, fn time -> time + 1 end)
      |> noreply()
    else
      noreply(socket)
    end
  end

Starting the timer

Since we set up all the plumbing and our UI can react to :tick events, we can finally introduce a timer.

For a while, I considered having an external process deal with time, and TimerLive would receive messages from that timer. But in the end, it proved simpler to start the timer in TimerLive itself.

Thus, when TimerLive is connected — meaning it’s not the initial stateless render of the page — we schedule an Erlang :timer that will tick every second (1_000 ms).

   def mount(_, _, socket) do
     socket =
       socket
       |> assign(:elapsed_time, 0)
       |> assign(:duration, 50)
+
+    if connected?(socket), do: schedule_timer()

     {:ok, socket}
   end
+
+  def schedule_timer do
+    :timer.send_interval(1_000, :tick)
+  end

Voilà! ✨

Testing

If you know me, you know I like test-driven development. And test-driving the implementation of TimerLive was a lot of fun.

It was because of tests that I wanted to control the passage of time, which lead me to create the TimerLive.tick/1 function. Through that function, my tests could increment time discretely.

Take a look at the test testing that the elapsed time changes with every tick:

  test "elapsed time is updated with every tick", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/timer")

    TimerLive.tick(view.pid)
    TimerLive.tick(view.pid)

    assert view |> elapsed_time("2 s") |> has_element?()
    assert view |> elapsed_time_gauge("2") |> has_element?()
  end

  defp elapsed_time_gauge(view, text \\ nil) do
    element(view, "#elapsed-time-gauge", text)
  end

  defp elapsed_time(view, text \\ nil) do
    element(view, "#elapsed-time", text)
  end

Testing the duration slider was also interesting. Since we had a phx-hook, we couldn’t test the JavaScript side, but we could test everything on the Elixir side via render_hook/3.

We targeted the #duration-slider element (which ensures we have phx-hook attached to it), and then we simulate the message our JavaScript hooks sends by passing the “update-duration” event with the %{"value" => "10"} payload.

  test "duration slider changes maximum elapsed time", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/timer")

    view
    |> element("#duration-slider")
    |> render_hook("update-duration", %{"value" => "10"})

The assertion was a little trickier. We wanted to make sure that changing the slider updated the gauge’s maximum time. To do that, we use a little bit of CSS-selector magic:

    assert view |> max_elapsed_time("10") |> has_element?()
  end

  defp max_elapsed_time(view, max) do
    element(view, "#elapsed-time-gauge[max=#{max}]")
  end

If you’re interested in seeing all the tests, take a look at the test file. And if you’re really interested in Testing LiveView, check out my Testing LiveView course.

Resources

These are links to the repo with all my examples and the commit for the Timer:

You can also find my posts for the previous tasks:

For a full description of all tasks, take a look at the 7 GUIs website.