prop/lib/microwaveprop_web/live/beacon_live/show.ex
Graham McIntire 2c1c221398 Change beacon height_ft from float to integer
Height in feet doesn't need decimal precision. Migrates the DB
column, updates schema type, and strips trailing .0 from form
input so the integer cast succeeds.
2026-04-11 09:44:42 -05:00

218 lines
7.3 KiB
Elixir

defmodule MicrowavepropWeb.BeaconLive.Show do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Beacons.RangeEstimate
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
{@beacon.callsign}
<:subtitle>
{Beacon.format_freq(@beacon.frequency_mhz)} MHz &middot; {@beacon.grid}
<span :if={not @beacon.approved} class="badge badge-warning badge-sm ml-2">
Pending approval
</span>
</:subtitle>
<:actions>
<.button
:if={admin?(@current_scope) and not @beacon.approved}
variant="primary"
phx-click="approve"
>
<.icon name="hero-check" /> Approve
</.button>
<.button navigate={~p"/beacons"}>
<.icon name="hero-arrow-left" />
</.button>
<.button
:if={admin?(@current_scope)}
variant="primary"
navigate={~p"/beacons/#{@beacon}/edit?return_to=show"}
>
<.icon name="hero-pencil-square" /> Edit
</.button>
</:actions>
</.header>
<div
id={"beacon-map-" <> @beacon.id}
phx-hook="BeaconMap"
phx-update="ignore"
class="h-96 w-full rounded-lg border border-base-300 mb-2"
data-lat={@beacon.lat}
data-lon={@beacon.lon}
data-label={"#{@beacon.callsign} · #{Beacon.format_freq(@beacon.frequency_mhz)} MHz"}
data-on-the-air={to_string(@beacon.on_the_air)}
data-grid-step={@estimate.grid_step}
data-cells={Jason.encode!(@estimate.cells)}
data-show-coverage={to_string(@show_coverage)}
>
</div>
<div class="mb-2 flex items-center justify-between flex-wrap gap-2">
<label class="label cursor-pointer gap-2 py-0">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@show_coverage}
phx-click="toggle_coverage"
/>
<span class="label-text text-sm">Show estimated current coverage</span>
</label>
</div>
<div :if={@show_coverage} class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3">
<span>
Band <strong>{@estimate.band_label}</strong> &middot;
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> &middot;
Atm loss <strong>{@estimate.atm_per_km} dB/km</strong>
</span>
<span>
Beacon grid score <strong>{@estimate.center_score}</strong>
<%= if @estimate.valid_time do %>
at {Calendar.strftime(@estimate.valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
(no HRRR data — defaulting cells to 50)
<% end %>
&middot; <strong>{length(@estimate.cells)}</strong>
cells rendered
</span>
</div>
<div :if={@show_coverage} class="flex flex-wrap gap-3 text-xs mb-4">
<%= for tier <- @estimate.tiers do %>
<div class="flex items-center gap-1.5">
<span
class="inline-block w-3 h-3 rounded-sm border"
style={"background-color: #{tier.color}; border-color: #{tier.color}"}
>
</span>
<span>
<strong>{tier.label}</strong> (≥ {tier.min_dbm} dBm)
</span>
</div>
<% end %>
</div>
<div class="rounded-lg border border-base-300 bg-base-200/40 p-3 text-sm">
<dl class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-4 gap-y-2">
<.stat_field label="Frequency">{Beacon.format_freq(@beacon.frequency_mhz)} MHz</.stat_field>
<.stat_field label="Callsign">{@beacon.callsign}</.stat_field>
<.stat_field label="Keying">{Beacon.keying_label(@beacon.keying)}</.stat_field>
<.stat_field label="On the air">
<span class={[
"badge badge-sm",
(@beacon.on_the_air && "badge-success") || "badge-ghost"
]}>
{if @beacon.on_the_air, do: "Yes", else: "No"}
</span>
</.stat_field>
<.stat_field label="Grid">{@beacon.grid}</.stat_field>
<.stat_field label="Latitude">{format_coord(@beacon.lat)}</.stat_field>
<.stat_field label="Longitude">{format_coord(@beacon.lon)}</.stat_field>
<.stat_field label="EIRP">{Beacon.format_mw(@beacon.power_mw)} mW</.stat_field>
<.stat_field label="Height AGL">{@beacon.height_ft} ft</.stat_field>
<.stat_field label="Bearing">{bearing_label(@beacon.bearing)}</.stat_field>
<.stat_field :if={@beacon.beamwidth_deg} label="Beamwidth">
{Beacon.format_mw(@beacon.beamwidth_deg)}°
</.stat_field>
</dl>
<div :if={@beacon.notes && @beacon.notes != ""} class="mt-3 pt-3 border-t border-base-300">
<div class="font-semibold text-[11px] uppercase tracking-wider opacity-60 mb-1">
Notes
</div>
<div class="whitespace-pre-wrap text-sm">{@beacon.notes}</div>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
if connected?(socket), do: Beacons.subscribe_beacons()
beacon = Beacons.get_beacon!(id)
{:ok,
socket
|> assign(:page_title, "Beacon")
|> assign(:beacon, beacon)
|> assign(:show_coverage, false)
|> assign(:estimate, RangeEstimate.estimate(beacon))}
end
@impl true
def handle_event("toggle_coverage", _params, socket) do
show = not socket.assigns.show_coverage
{:noreply,
socket
|> assign(:show_coverage, show)
|> push_event("toggle_coverage", %{show: show})}
end
def handle_event("approve", _params, socket) do
if admin?(socket.assigns.current_scope) do
{:ok, approved} = Beacons.approve_beacon(socket.assigns.beacon)
{:noreply,
socket
|> assign(:beacon, approved)
|> put_flash(:info, "Beacon approved.")}
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
end
@impl true
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
{:noreply,
socket
|> assign(:beacon, beacon)
|> assign(:estimate, RangeEstimate.estimate(beacon))}
end
def handle_info({:deleted, %Beacon{id: id}}, %{assigns: %{beacon: %{id: id}}} = socket) do
{:noreply,
socket
|> put_flash(:error, "This beacon was deleted.")
|> push_navigate(to: ~p"/beacons")}
end
def handle_info({type, %Beacon{}}, socket) when type in [:created, :updated, :deleted] do
{:noreply, socket}
end
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
defp bearing_label(nil), do: "Omni"
defp bearing_label("omni"), do: "Omni"
defp bearing_label(value), do: "#{value}°"
defp format_coord(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 6)
defp format_coord(value), do: to_string(value)
attr :label, :string, required: true
slot :inner_block, required: true
defp stat_field(assigns) do
~H"""
<div class="min-w-0">
<dt class="font-semibold text-[11px] uppercase tracking-wider opacity-60">
{@label}
</dt>
<dd class="truncate">{render_slot(@inner_block)}</dd>
</div>
"""
end
end