prop/lib/microwaveprop_web/live/contact_live/index.ex

227 lines
7.5 KiB
Elixir

defmodule MicrowavepropWeb.ContactLive.Index do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Radio
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)
@default_sort_by "inserted_at"
@default_sort_order "desc"
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
page =
case Integer.parse(Map.get(params, "page", "1")) do
{n, _} -> max(n, 1)
:error -> 1
end
sort_by = validate_sort_field(Map.get(params, "sort_by", @default_sort_by))
sort_order = validate_sort_order(Map.get(params, "sort_order", @default_sort_order))
search = Map.get(params, "search", "")
result =
Radio.list_contacts(
page: page,
sort_by: String.to_existing_atom(sort_by),
sort_order: String.to_existing_atom(sort_order),
search: search
)
{:noreply,
assign(socket,
page_title: "Contacts",
page: result.page,
total_pages: result.total_pages,
total_entries: result.total_entries,
contacts: result.entries,
grouped_contacts: result.grouped_entries,
sort_by: sort_by,
sort_order: sort_order,
search: search
)}
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
{:noreply, push_patch(socket, to: ~p"/contacts?search=#{search}")}
end
@impl true
def handle_event("sort", %{"field" => field}, socket) do
field = validate_sort_field(field)
new_order =
if socket.assigns.sort_by == field && socket.assigns.sort_order == "asc",
do: "desc",
else: "asc"
params = %{sort_by: field, sort_order: new_order}
params = if socket.assigns.search == "", do: params, else: Map.put(params, :search, socket.assigns.search)
{:noreply, push_patch(socket, to: ~p"/contacts?#{params}")}
end
defp validate_sort_field(field) when field in @sortable_fields, do: field
defp validate_sort_field(_), do: @default_sort_by
defp validate_sort_order(order) when order in ~w(asc desc), do: order
defp validate_sort_order(_), do: @default_sort_order
@enrichment_fields [:hrrr_status, :weather_status, :terrain_status, :iemre_status]
@done_statuses [:complete, :unavailable]
defp enrichment_badge(contact) do
statuses = Enum.map(@enrichment_fields, &Map.get(contact, &1))
done = Enum.count(statuses, &(&1 in @done_statuses))
failed = Enum.count(statuses, &(&1 == :failed))
{label, class} =
cond do
done == 4 -> {"Complete", "badge-success"}
failed > 0 -> {"Failed", "badge-error"}
done > 0 -> {"Partial", "badge-warning"}
true -> {"Pending", "badge-ghost"}
end
assigns = %{label: label, class: class}
~H"""
<span class={["badge badge-xs", @class]}>{@label}</span>
"""
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} max_width="max-w-7xl">
<.header>
Contacts
<:subtitle>{@total_entries} contacts</:subtitle>
<:actions>
<.link navigate={~p"/submit"} class="btn btn-primary">
<.icon name="hero-plus" class="w-5 h-5" /> Submit Contact
</.link>
</:actions>
</.header>
<form phx-submit="search" class="mb-4">
<div class="flex items-center gap-2">
<input
name="search"
value={@search}
placeholder="Search callsign or pair (e.g. W5LUA W5HN)..."
type="text"
class="input input-bordered input-sm w-64"
/>
<button type="submit" class="btn btn-sm btn-outline">Search</button>
<.link :if={@search != ""} patch={~p"/contacts"} class="btn btn-sm btn-ghost">Clear</.link>
</div>
</form>
<table class="table table-sm w-full">
<thead>
<tr>
<th class="w-8"></th>
<th>Station 1</th>
<th>Grid 1</th>
<th>Station 2</th>
<th>Grid 2</th>
<th>Band</th>
<th>Mode</th>
<th>Distance (km)</th>
<th>Submitted</th>
<th>Enriched</th>
<th>Timestamp (UTC)</th>
<th>Added (UTC)</th>
</tr>
</thead>
<tbody>
<%= for {primary, reciprocals} <- @grouped_contacts do %>
<tr class="h-1">
<td colspan="12"></td>
</tr>
<tr
class={[
"hover:bg-base-200 cursor-pointer",
reciprocals != [] && "bg-primary/5"
]}
phx-click={JS.navigate(~p"/contacts/#{primary.id}")}
>
<td class="w-8 text-center">
<span
:if={reciprocals != []}
class="badge badge-primary badge-xs"
title="Has reciprocal contact"
>
{length(reciprocals) + 1}
</span>
</td>
<td class="font-mono font-semibold">{primary.station1}</td>
<td>{primary.grid1 || "—"}</td>
<td class="font-mono font-semibold">{primary.station2}</td>
<td>{primary.grid2 || "—"}</td>
<td>{primary.band}</td>
<td>{primary.mode}</td>
<td>{primary.distance_km}</td>
<td>{if primary.user_submitted, do: "Yes", else: "—"}</td>
<td>{enrichment_badge(primary)}</td>
<td>{Calendar.strftime(primary.qso_timestamp, "%Y-%m-%d %H:%M")}</td>
<td>{Calendar.strftime(primary.inserted_at, "%Y-%m-%d %H:%M")}</td>
</tr>
<%= for recip <- reciprocals do %>
<tr
class="hover:bg-base-200 cursor-pointer bg-primary/5 opacity-70 border-t-0"
phx-click={JS.navigate(~p"/contacts/#{recip.id}")}
>
<td class="w-8 text-center text-primary">↳</td>
<td class="font-mono">{recip.station1}</td>
<td>{recip.grid1 || "—"}</td>
<td class="font-mono">{recip.station2}</td>
<td>{recip.grid2 || "—"}</td>
<td>{recip.band}</td>
<td>{recip.mode}</td>
<td>{recip.distance_km}</td>
<td>{if recip.user_submitted, do: "Yes", else: "—"}</td>
<td>{enrichment_badge(recip)}</td>
<td>{Calendar.strftime(recip.qso_timestamp, "%Y-%m-%d %H:%M")}</td>
<td>{Calendar.strftime(recip.inserted_at, "%Y-%m-%d %H:%M")}</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
<div class="flex items-center justify-between pt-4">
<.link
:if={@page > 1}
patch={
~p"/contacts?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
}
class="btn btn-sm btn-outline"
>
<.icon name="hero-chevron-left" class="w-4 h-4" /> Previous
</.link>
<span :if={@page <= 1} />
<span class="text-sm text-base-content/70">Page {@page} of {@total_pages}</span>
<.link
:if={@page < @total_pages}
patch={
~p"/contacts?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
}
class="btn btn-sm btn-outline"
>
Next <.icon name="hero-chevron-right" class="w-4 h-4" />
</.link>
<span :if={@page >= @total_pages} />
</div>
</Layouts.app>
"""
end
end