prop/lib/microwaveprop_web/live/qso_live/show.ex
Graham McIntire 480480bde3
Fix QSO show page weather lookup and add expandable sounding profiles
Fix pos1 longitude key mismatch (lng vs lon) that caused empty weather
results for all QSOs. Bump search radius to 300km for sounding coverage.
Soundings now show as expandable cards with full elevation profile data.
2026-03-29 14:33:14 -05:00

292 lines
10 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",
expanded_soundings: MapSet.new()
)}
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("toggle_profile", %{"id" => id}, socket) do
expanded =
if MapSet.member?(socket.assigns.expanded_soundings, id) do
MapSet.delete(socket.assigns.expanded_soundings, id)
else
MapSet.put(socket.assigns.expanded_soundings, id)
end
{:noreply, assign(socket, expanded_soundings: expanded)}
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
lat = qso.pos1 && qso.pos1["lat"]
lon = qso.pos1 && (qso.pos1["lon"] || qso.pos1["lng"])
if lat && lon do
Weather.weather_for_qso(%{lat: lat, lon: lon, timestamp: qso.qso_timestamp},
radius_km: 300
)
else
%{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 %>
<%= for s <- @soundings do %>
<div class="mb-6 border border-base-300 rounded-lg">
<button
phx-click="toggle_profile"
phx-value-id={s.id}
class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
>
<div class="flex items-center gap-4 text-sm">
<span class="font-semibold">{s.station.name || s.station.station_code}</span>
<span>{Calendar.strftime(s.observed_at, "%H:%M UTC")}</span>
<span>{s.level_count} levels</span>
<span>Sfc: {format_number(s.surface_temp_c)}°C</span>
<span>N: {format_number(s.surface_refractivity)}</span>
<span>K: {format_number(s.k_index)}</span>
<span>LI: {format_number(s.lifted_index)}</span>
<%= if s.ducting_detected do %>
<span class="badge badge-warning badge-sm">Ducting</span>
<% end %>
</div>
<.icon
name={
if MapSet.member?(@expanded_soundings, s.id),
do: "hero-chevron-up",
else: "hero-chevron-down"
}
class="w-5 h-5"
/>
</button>
<%= if MapSet.member?(@expanded_soundings, s.id) do %>
<div class="px-4 pb-4">
<div class="overflow-x-auto">
<table class="table table-xs table-zebra">
<thead>
<tr>
<th>Pressure (mb)</th>
<th>Height (m)</th>
<th>Temp (°C)</th>
<th>Dewpoint (°C)</th>
<th>Wind Dir (°)</th>
<th>Wind Spd (kts)</th>
</tr>
</thead>
<tbody>
<%= for level <- filter_profile(s.profile) do %>
<tr>
<td>{format_number(level["pres"])}</td>
<td>{format_number(level["hght"])}</td>
<td>{format_number(level["tmpc"])}</td>
<td>{format_number(level["dwpc"])}</td>
<td>{format_number(level["drct"])}</td>
<td>{format_number(level["sknt"])}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% end %>
<% 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 filter_profile(profile) do
Enum.filter(profile, fn level ->
level["tmpc"] != nil || level["dwpc"] != nil
end)
end
defp format_kp(nil), do: ""
defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1)
end