lib/workbench_web/live/order_live/index.ex

defmodule WorkbenchWeb.OrderLive.Index do
  use WorkbenchWeb, :live_view
  import WorkbenchWeb.ViewHelpers.NodeHelper, only: [assign_node: 2]
  import WorkbenchWeb.ViewHelpers.PaginationHelper, only: [assign_pagination: 2]
  import WorkbenchWeb.ViewHelpers.SearchQueryHelper, only: [assign_search_query: 2]

  @impl true
  def mount(_params, _session, socket) do
    Phoenix.PubSub.subscribe(Tai.PubSub, "order_updated:*")

    socket =
      socket
      |> assign(:new_follow_orders_count, 0)
      |> assign(:query, nil)
      |> assign(:last_order_updated, nil)

    {:ok, socket}
  end

  @impl true
  def handle_params(params, _uri, socket) do
    socket =
      socket
      |> assign(:follow, Map.get(params, "follow") != "false")
      |> assign_node(params)
      |> assign_pagination(params)
      |> assign_search_query(params)
      |> assign_search()

    {:noreply, socket}
  end

  @impl true
  def handle_event("search", params, socket) do
    socket =
      socket
      |> cancel_search_timer()
      |> cancel_load_placeholder_timer()
      |> assign_search_query(params)
      |> send_search_after(200)

    {:noreply, socket}
  end

  @impl true
  def handle_event("clear-orders", _params, socket) do
    selected_node = String.to_atom(socket.assigns.node)
    {_, _} = Tai.Commander.delete_all_orders(node: selected_node)

    socket =
      socket
      |> assign_search()

    {:noreply, socket}
  end

  @impl true
  def handle_event("toggle-follow", _params, socket) do
    follow = !socket.assigns.follow

    socket_with_follow =
      socket
      |> assign(:follow, follow)

    if follow do
      socket =
        socket_with_follow
        |> assign_search()
        |> push_patch(to: Routes.order_path(socket_with_follow, :index))

      {:noreply, socket}
    else
      {:noreply, socket_with_follow}
    end
  end

  @impl true
  def handle_info({:node_selected, _selected_node}, socket) do
    socket =
      socket
      |> assign_search()
      |> push_patch(to: Routes.order_path(socket, :index, %{query: socket.assigns.query}))

    {:noreply, socket}
  end

  @impl true
  def handle_info(:search, socket) do
    socket =
      socket
      |> assign(:search_timer, nil)
      |> assign_search()

    {:noreply, socket}
  end

  @impl true
  def handle_info(:load_placeholder_orders, socket) do
    visible_orders = socket.assigns.orders
    placeholder_orders = Enum.filter(visible_orders, &(&1.venue == nil))
    search_node = String.to_atom(socket.assigns.node)

    loaded_new_orders =
      placeholder_orders
      |> Enum.map(& &1.client_id)
      |> Tai.Commander.get_orders_by_client_ids(node: search_node)
      |> Map.new(fn o -> {o.client_id, o} end)

    orders =
      visible_orders
      |> Enum.map(fn o ->
        if o.venue == nil do
          Map.get(loaded_new_orders, o.client_id) || o
        else
          o
        end
      end)

    socket =
      socket
      |> assign(:load_placeholder_timer, nil)
      |> assign(:new_follow_orders_count, 0)
      |> assign(:orders, orders)

    {:noreply, socket}
  end

  @impl true
  def handle_info(:clear_last_order_updated, socket) do
    socket =
      socket
      |> assign(:last_order_updated, nil)

    {:noreply, socket}
  end

  @impl true
  def handle_info({:order_updated, client_id, transition}, socket) do
    socket =
      socket
      |> assign(:last_order_updated, client_id)
      |> send_clear_last_order_updated_after(5000)
      |> update_or_search_orders(client_id, transition)

    {:noreply, socket}
  end

  defp update_or_search_orders(socket, client_id, nil) do
    # DEV NOTE:
    # This is an optimization to reduce load on the DB. When we're following
    # newly created orders and there was no search query, we can create a
    # placeholder row with the minimal information that we have to immediately
    # indicate in the UI that a new order was created.
    #
    # We then debounce a full search when based on two conditions:
    # 1. There are N new orders displayed that haven't been loaded
    # 2. T milliseconds have elapsed without the placeholder orders being loaded
    cond do
      socket.assigns.follow && socket.assigns.query == nil ->
        orders = socket.assigns.orders
        orders_count = socket.assigns.orders_count + 1
        new_follow_orders_count = socket.assigns.new_follow_orders_count + 1
        new_order = %Tai.Orders.Order{client_id: client_id, status: :enqueued}

        updated_orders =
          if length(orders) >= socket.assigns.page_size do
            [new_order]
          else
            orders ++ [new_order]
          end

        socket
        |> cancel_search_timer()
        |> assign(:new_follow_orders_count, new_follow_orders_count)
        |> assign(:orders_count, orders_count)
        |> assign_pages()
        |> assign(:orders, updated_orders)
        |> send_load_placeholder_orders_after(500)

      socket.assigns.follow ->
        socket
        |> cancel_load_placeholder_timer()
        |> send_search_after(500)

      true ->
        socket
    end
  end

  defp update_or_search_orders(socket, client_id, transition) do
    orders = socket.assigns.orders
    visible_updated_order = Enum.find(orders, &(&1.client_id == client_id))

    # DEV NOTE:
    # This is an optimization to reduce load on the DB. When the updated order
    # is visible in the current page the transition contains all of the required
    # attributes that have been updated. We can just apply them in memory so that
    # we don't need to read from the DB again. When the updated order is not
    # visible in the current page then we don't need to do anything.
    if visible_updated_order do
      %transition_mod{} = transition
      attrs = transition |> transition_mod.attrs() |> Map.new()
      new_status = transition_mod.status(visible_updated_order.status)
      updated_attrs = Map.put(attrs, :status, new_status)

      updated_orders =
        Enum.map(orders, fn o ->
          if o.client_id == client_id do
            Map.merge(o, updated_attrs)
          else
            o
          end
        end)

      socket
      |> assign(:orders, updated_orders)
    else
      socket
    end
  end

  defp cancel_search_timer(socket) do
    timer = socket.assigns[:search_timer]

    if timer do
      Process.cancel_timer(timer)
    end

    socket
    |> assign(:search_timer, nil)
  end

  defp cancel_load_placeholder_timer(socket) do
    timer = socket.assigns[:load_placeholder_timer]

    if timer do
      Process.cancel_timer(timer)
    end

    socket
    |> assign(:load_placeholder_timer, nil)
  end

  defp send_search_after(socket, after_ms) do
    if socket.assigns[:search_timer] do
      socket
    else
      timer = Process.send_after(self(), :search, after_ms)
      assign(socket, :search_timer, timer)
    end
  end

  defp send_load_placeholder_orders_after(socket, after_ms) do
    if socket.assigns[:load_placeholder_timer] do
      socket
    else
      timer = Process.send_after(self(), :load_placeholder_orders, after_ms)
      assign(socket, :load_placeholder_timer, timer)
    end
  end

  defp send_clear_last_order_updated_after(socket, after_ms) do
    timer = Process.send_after(self(), :clear_last_order_updated, after_ms)

    socket
    |> assign(:clear_last_order_updated_timer, timer)
  end

  defp assign_search(socket) do
    query = socket.assigns.query
    search_node = String.to_atom(socket.assigns.node)
    orders_count = Tai.Commander.orders_count(query, node: search_node)

    socket_with_assigned_pages =
      socket
      |> assign(:new_follow_orders_count, 0)
      |> assign(:orders_count, orders_count)
      |> assign_pages()

    orders =
      Tai.Commander.orders(query,
        page: socket_with_assigned_pages.assigns.current_page,
        page_size: socket_with_assigned_pages.assigns.page_size,
        node: search_node
      )

    socket_with_assigned_pages
    |> assign(:orders, orders)
  end

  defp assign_pages(socket) do
    last_page = max(ceil(socket.assigns.orders_count / socket.assigns.page_size), 1)
    first_page = 1
    assigned_page = if socket.assigns.follow, do: last_page, else: socket.assigns.current_page
    current_page = if assigned_page > last_page, do: first_page, else: assigned_page

    if current_page != assigned_page do
      socket
      |> put_flash(
        :warn,
        "Page parameter=#{assigned_page} is > than the last available page=#{last_page}. The orders table is using the first page=#{first_page} instead."
      )
    else
      socket
    end
    |> assign(:current_page, current_page)
    |> assign(:last_page, last_page)
  end

  defp to(socket, page, current_page, _follow) when page < current_page do
    Routes.order_path(socket, :index, page: page, follow: false)
  end

  defp to(socket, page, _current_page, follow) do
    Routes.order_path(socket, :index, page: page, follow: follow)
  end

  defp render_ping(follow) do
    animate_color_class = unless follow, do: "bg-gray-400"
    relative_color_class = unless follow, do: "bg-gray-500"

    assigns = [
      animate: follow,
      animate_color_class: animate_color_class,
      relative_color_class: relative_color_class
    ]

    ~H"""
    <%= render Stylish.Ping, "ping.html", assigns %>
    """
  end
end