prop/lib/microwaveprop_web/live/qso_live/show.ex
Graham McIntire 6f16395f44
Add QSO import, solar indices, Oban workers, LiveView UI, and parallel weather import
- Radio context with QSO schema and CSV import script (58K contacts)
- Solar index schema, GFZ client, and daily Oban cron worker
- LiveView dashboard with QSO table and weather correlation views
- Parallelize weather import script using Task.async_stream (10 concurrent workers)
- Replace sequential rate_limit sleeps with concurrency-based backpressure
- Atomic progress counter for interleave-safe reporting
2026-03-29 13:04:55 -05:00

236 lines
8.5 KiB
Elixir

defmodule MicrowavepropWeb.QsoLive.Show do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Radio
alias Microwaveprop.Weather
@impl true
def mount(%{"id" => id}, _session, socket) do
qso = Radio.get_qso!(id)
weather = load_weather(qso)
solar = load_solar(qso)
{:ok,
assign(socket,
page_title: "#{qso.station1} / #{qso.station2}",
qso: qso,
surface_observations: weather.surface_observations,
soundings: weather.soundings,
solar: solar,
obs_sort_by: "station_name",
obs_sort_order: "asc",
sounding_sort_by: "station_name",
sounding_sort_order: "asc"
)}
end
@impl true
def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do
{sort_by, sort_order} =
toggle_sort(socket.assigns.obs_sort_by, socket.assigns.obs_sort_order, field)
sorted = sort_observations(socket.assigns.surface_observations, sort_by, sort_order)
{:noreply,
assign(socket,
surface_observations: sorted,
obs_sort_by: sort_by,
obs_sort_order: sort_order
)}
end
def handle_event("sort", %{"field" => field, "table" => "soundings"}, socket) do
{sort_by, sort_order} =
toggle_sort(socket.assigns.sounding_sort_by, socket.assigns.sounding_sort_order, field)
sorted = sort_soundings(socket.assigns.soundings, sort_by, sort_order)
{:noreply,
assign(socket,
soundings: sorted,
sounding_sort_by: sort_by,
sounding_sort_order: sort_order
)}
end
defp toggle_sort(current_field, current_order, new_field) do
if current_field == new_field && current_order == "asc",
do: {new_field, "desc"},
else: {new_field, "asc"}
end
defp sort_observations(observations, sort_by, sort_order) do
sorter = obs_sort_key(sort_by)
sorted = Enum.sort_by(observations, sorter)
if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
end
defp obs_sort_key("station_name"), do: fn obs -> obs.station.name || obs.station.station_code end
defp obs_sort_key("observed_at"), do: & &1.observed_at
defp obs_sort_key("temp_f"), do: &(&1.temp_f || 0)
defp obs_sort_key("dewpoint_f"), do: &(&1.dewpoint_f || 0)
defp obs_sort_key("relative_humidity"), do: &(&1.relative_humidity || 0)
defp obs_sort_key("sea_level_pressure_mb"), do: &(&1.sea_level_pressure_mb || 0)
defp obs_sort_key(_), do: fn obs -> obs.station.name || obs.station.station_code end
defp sort_soundings(soundings, sort_by, sort_order) do
sorter = sounding_sort_key(sort_by)
sorted = Enum.sort_by(soundings, sorter)
if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
end
defp sounding_sort_key("station_name"), do: fn s -> s.station.name || s.station.station_code end
defp sounding_sort_key("observed_at"), do: & &1.observed_at
defp sounding_sort_key("surface_temp_c"), do: &(&1.surface_temp_c || 0)
defp sounding_sort_key("surface_refractivity"), do: &(&1.surface_refractivity || 0)
defp sounding_sort_key("k_index"), do: &(&1.k_index || 0)
defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0)
defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
defp load_weather(qso) do
case qso.pos1 do
%{"lat" => lat, "lon" => lon} ->
Weather.weather_for_qso(%{lat: lat, lon: lon, timestamp: qso.qso_timestamp})
_ ->
%{surface_observations: [], soundings: []}
end
end
defp load_solar(qso) do
qso.qso_timestamp
|> DateTime.to_date()
|> Weather.get_solar_index()
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@qso.station1} / {@qso.station2}
<:subtitle>{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
<:actions>
<.link navigate={~p"/qsos"} class="btn btn-sm btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to QSOs
</.link>
</:actions>
</.header>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">QSO Details</h2>
<.list>
<:item title="Station 1">{@qso.station1}</:item>
<:item title="Station 2">{@qso.station2}</:item>
<:item title="Band">{@qso.band} MHz</:item>
<:item title="Mode">{@qso.mode}</:item>
<:item title="Grid 1">{@qso.grid1 || "—"}</:item>
<:item title="Grid 2">{@qso.grid2 || "—"}</:item>
<:item title="Distance">{@qso.distance_km || "—"} km</:item>
<:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:item>
</.list>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
<%= if @surface_observations == [] do %>
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
<% else %>
<.table
id="surface-obs"
rows={@surface_observations}
row_id={fn obs -> "obs-#{obs.id}" end}
sort_by={@obs_sort_by}
sort_order={@obs_sort_order}
sort_target="obs"
>
<:col :let={obs} label="Station" sort_field="station_name">
{obs.station.name || obs.station.station_code}
</:col>
<:col :let={obs} label="Time" sort_field="observed_at">
{Calendar.strftime(obs.observed_at, "%H:%M")}
</:col>
<:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"}</:col>
<:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f">
{obs.dewpoint_f || "—"}
</:col>
<:col :let={obs} label="RH%" sort_field="relative_humidity">
{format_number(obs.relative_humidity)}
</:col>
<:col :let={obs} label="Wind">{format_wind(obs)}</:col>
<:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb">
{obs.sea_level_pressure_mb || "—"}
</:col>
<:col :let={obs} label="Sky">{obs.sky_condition || "—"}</:col>
</.table>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Soundings</h2>
<%= if @soundings == [] do %>
<p class="text-sm text-base-content/50 italic">No soundings found nearby.</p>
<% else %>
<.table
id="soundings"
rows={@soundings}
row_id={fn s -> "sounding-#{s.id}" end}
sort_by={@sounding_sort_by}
sort_order={@sounding_sort_order}
sort_target="soundings"
>
<:col :let={s} label="Station" sort_field="station_name">
{s.station.name || s.station.station_code}
</:col>
<:col :let={s} label="Time" sort_field="observed_at">
{Calendar.strftime(s.observed_at, "%H:%M")}
</:col>
<:col :let={s} label="Sfc Temp (C)" sort_field="surface_temp_c">
{s.surface_temp_c || "—"}
</:col>
<:col :let={s} label="Refractivity" sort_field="surface_refractivity">
{format_number(s.surface_refractivity)}
</:col>
<:col :let={s} label="Ducting">{if s.ducting_detected, do: "Yes", else: "No"}</:col>
<:col :let={s} label="K-Index" sort_field="k_index">{format_number(s.k_index)}</:col>
<:col :let={s} label="Lifted Index" sort_field="lifted_index">
{format_number(s.lifted_index)}
</:col>
</.table>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
<%= if @solar do %>
<.list>
<:item title="SFI">{@solar.sfi || "—"}</:item>
<:item title="Sunspot Number">{@solar.sunspot_number || "—"}</:item>
<:item title="Ap Index">{@solar.ap_index || "—"}</:item>
<:item title="Kp Values">{format_kp(@solar.kp_values)}</:item>
</.list>
<% else %>
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
<% end %>
</Layouts.app>
"""
end
defp format_number(nil), do: ""
defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
defp format_number(n), do: to_string(n)
defp format_wind(obs) do
case {obs.wind_direction_deg, obs.wind_speed_kts} do
{nil, nil} -> ""
{dir, speed} -> "#{dir || "?"}° @ #{speed || "?"} kts"
end
end
defp format_kp(nil), do: ""
defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1)
end