Building a Baby Age Calculuator with Phoenix/Elixir LiveView

In this article I share how I was able to create a Baby Age Calculator using Phoenix/Elixir LiveView.

If you just want to know how old your child is, jump over to babyweeks.herokuapp.com. If you want to download the source code, goto github.com/monkseal/babyweeks-phx-liveview

Building a Baby Age Calculuator with Phoenix/Elixir LiveView

LiveView - Phoenix/Elixir’s Javascript killer?

As described here and here — “Phoenix LiveView is an exciting new library which enables rich, real-time user experiences with server-rendered HTML. LiveView powered applications are stateful on the server with bidrectional communication via WebSockets, offering a vastly simplified programming model compared to JavaScript alternatives.”

If you don’t want to write Javascript to have a real-time web application, Phoenix LiveView provides an interesting alternative with server-rendered HTML.

Being one to avoid bold pronouments, I won’t commit myself to say that LiveView will deal a death blow to javascript in your app. For one, you may already have a lot Javascript in your app. Maybe some if you even like. However, if you are interested in managing state on the server, LiveView is quite compelling.

Project Setup

I don’t want to write a tutorial on setting up your enviroment, you can find a good intro at elixirschool’s Walk-Through of Phoenix LiveView and the phoenix_live_view README. After following those project seetup examples, I ended up with the following files/that we different than a regular elixir setup:

Let’s take a look at this one by one.

Live view template

Our live view template references two variables. The @error variable contains an error message (or nil). This will render if there is input error. The @calc contains a struct defined in our Babyweeks.Calculator. We have 3 different events we are interacting with up, down and compute. The up/down was added to make the interface a little easier to use on mobile.

The remaining template looks like a regular view.

<div>
  <div>
    This app tells you how old your baby is. Enter the birthday and click 'Compute'
  </div>
  <form action="#" phx-submit="compute">
    <%= if @error do %>
      <h3 class="alert-danger"><%=  %></h3>
    <% end %>

    <table>
      <tbody>
        <tr>
          <th>Month</th>
          <th>Day</th>
          <th>Year</th>
        </tr>
        <tr class="calendar-row">
          <td>
            <button phx-click="up" phx-value="months"><span>&uarr;</span></button>
            <%= number_input :bday, :m, size: '5', value: @calc.bday.month  %>
            <button phx-click="down" phx-value="months"><span>&darr;</span></button>
          </td>

          <td>
            <button phx-click="up" phx-value="days"><span>&uarr;</span></button>
            <%= number_input :bday, :d, size: '5', value: @calc.bday.day %>
            <button phx-click="down" phx-value="days"><span>&darr;</span></button>
            </td>
          <td>
            <button phx-click="up" phx-value="years"><span>&uarr;</span></button>
            <%= number_input :bday, :y, size: '5', value: @calc.bday.year %>
            <button phx-click="down" phx-value="years"><span>&darr;</span></button>
          </td>
        </tr>
      </tbody>
    </table>

    <%= submit "Compute", phx_disable_with: "Adding..." %>

    <%= if !@error do %>
      <h3><%= @calc.weeks %> weeks, <%= @calc.days %> days old</h3>
      <table>
        <tbody>
          <tr><td>Today is:</td><td><%= Timex.format!(@calc.today, "%B %d, %Y", :strftime) %></td></tr>
          <tr><td>Age in days:</td><td><%= @calc.age_in_days %></td></tr>
          <tr><td>Age in months:</td><td><%= @calc.age_in_months %></td></tr>
          <tr><td>Age in years:</td><td><%= @calc.age_in_years %></td></tr>
        </tbody>
      </table>
    <% end %>
  </form>
</div>

The baby weeks Liveview

My first day playing with Liveview resulted in a massive LiveView file with different functions all over the place and business logic scattered throuhgout the different handle_event functions. This became a smell to me. I created a rule for myself that only display and update logic would be present in my BabyweeksWeb.BabyweeksLiveView. This approach seems to work well. As you can see, our handle_event methods are small and easy to reason about.

defmodule BabyweeksWeb.BabyweeksLiveView do
  use Phoenix.LiveView

  alias Babyweeks.Calculator
  alias BabyweeksWeb.BabyweeksView

  def render(assigns) do
    BabyweeksView.render("index.html", assigns)
  end

  def mount(_session, socket) do
    calc = Calculator.default_bday_to_today(%Calculator{})
    {:ok, assign(socket, calc: calc, error: nil)}
  end

  def handle_event("compute", %{"bday" => bday}, %{assigns: assigns} = socket) do
    case Calculator.from_params(assigns.calc, bday) do
      {:ok, updated_calc} ->
        {:noreply,
        assign(socket, error: nil, calc: Calculator.compute_weeks_and_days(updated_calc))
        }

      {:error, message} ->
        {:noreply,
         assign(socket, error: message)}
    end
  end

  def handle_event("up", value, socket) do
    interval = String.to_existing_atom(value)

    {:noreply,
     update(socket, :calc, fn calc ->
       calc |> Calculator.handle_up(interval) |> Calculator.compute_weeks_and_days()
     end)}
  end

  def handle_event("down", value, socket) do
    interval = String.to_existing_atom(value)

    {:noreply,
     update(socket, :calc, fn calc ->
       calc |> Calculator.handle_down(interval) |> Calculator.compute_weeks_and_days()
     end)}
  end
end

The baby weeks Calculator.

If you come from the React/Redux school of UI state management (as I clearly have), our Babyweeks.Calculator module acts like our action reducers. It takes one version of a Babyweeks.Calculator struct and spits out a new one.

Our Calculator struct take a simple flat shape with today and bday being the promenient values. A design goal is that our above BabyweeksWeb.BabyweeksLiveView does not need to know about the shape of this struct. However, our template does as it is displaying the values.

defmodule Babyweeks.Calculator do
  defstruct bday: nil,
            age_in_weeks: 0,
            age_in_weeks_additional_days: 0,
            age_in_months: 0,
            age_in_years: 0,
            age_in_days: 0,
            today: nil
  # ...
end

Our important state transtion function is Calculator.compute_weeks_and_days which returns a new calculator struct with computed values for the above age_in_x fields.

In pure functional fashion, we can use composition and chain our function calls together. This is where Elixir shines:

  def compute_weeks_and_days(calc, end_date \\ today()) do
    calc
    |> compute_weeks(end_date, calc.bday)
    |> compute_additional_days(end_date, calc.bday)
    |> compute_age_in_days(end_date, calc.bday)
    |> compute_age_in_months(end_date, calc.bday)
    |> compute_age_in_years(end_date, calc.bday)
  end

The smaller function make use of Elixir Timex library for data calculation, here’s one example:

  def compute_weeks(calc, end_date, start_date) do
    %{calc | age_in_weeks: Timex.diff(end_date, start_date, :weeks)}
  end

We also have functions to handle the up and down buttons over the month/day/year input values:

  def handle_up(calc, interval \\ :days) do
    options = [{interval, 1}]
    shift_bday(calc, options)
  end

  def handle_down(calc, interval \\ :days) do
    options = [{interval, -1}]
    shift_bday(calc, options)
  end

  def shift_bday(calc, shift_options) do
    shifted = Timex.shift(calc.bday, shift_options)
    %{calc | bday: shifted}
  end

Closing thoughts

If you are designing a LiveView application, model the application state transition separate from the liveview mechanics. This will save you a lot of trouble and allow you to think about

I’m very excited about liveview and looking forward to using in future applications. In particular, even though Liveview has been dubbed a replacement for javascript, it will be interesting to see how we can use it integrate with existing javascript. It is a good abstraction over websockets that could have other uses.