prop/lib/microwaveprop_web/live/weather_map_live.ex
Graham McIntire e3d430f8c4
feat(weather): add duct cutoff band map layer
Show on /weather where the detected duct geometry traps each microwave
band. Overview mode bins cells by their lowest trapped frequency
(Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't
reach the selected band. Cutoff is pre-computed per cell in both
writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from
CellValues), persisted to ScalarFile, and shipped to the JS hook via
the existing binary cell pack. Also cleans up six pre-existing
length/1 credo warnings in unrelated test files.
2026-05-15 19:51:24 -05:00

759 lines
27 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.WeatherMapLive do
@moduledoc "`/weather` — HRRR-derived forecast fields (temp, Td, wind, refractivity) on a map."
use MicrowavepropWeb, :live_view
alias Microwaveprop.Weather
alias Microwaveprop.Weather.MapLayers
@layers MapLayers.all()
@default_layer MapLayers.default_id()
# Canonical amateur microwave bands for the duct-cutoff layer picker.
# Buttons render in this order; an "All" reset clears the filter.
@cutoff_bands [10, 24, 47, 76, 122, 241]
# Default map viewport. Used when no `lat`/`lon`/`zoom` URL params are
# provided (the deep-link case). Centered on DFW (the project's home
# area) at z=7 — same defaults the JS hook used to hardcode.
@default_center_lat 32.897
@default_center_lon -97.038
@default_zoom 7
@min_zoom 4
@max_zoom 10
@impl true
def mount(params, _session, socket) do
_ =
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
end
# Default the timeline cursor to the valid_time closest to "now" so
# users land on current conditions, not a +11h forecast hour (which
# the underlying cache can advance to while the worker is walking
# f00→f18).
#
# Mount stays cheap — no grid materialisation. The dead render
# would otherwise spend ~510 s materialising 92k cells through
# `WeatherLayers.derive`, blocking the HTTP response and tripping
# the LiveView socket join timeout. The client now paints weather
# through a tile endpoint, so mount only needs the selected layer
# and valid_time metadata.
valid_times = recent_valid_times(Weather.available_weather_valid_times())
initial_vt = pick_initial_valid_time(valid_times)
{lat, lon, zoom} = parse_viewport_params(params)
{:ok,
assign(socket,
page_title: "Weather Map",
layers: @layers,
selected_layer: @default_layer,
cutoff_bands: @cutoff_bands,
band_filter: nil,
valid_time: initial_vt,
valid_times: valid_times,
initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)),
initial_selected_time: initial_vt && DateTime.to_iso8601(initial_vt),
initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"),
initial_center_lat: lat,
initial_center_lon: lon,
initial_zoom: zoom,
current_lat: lat,
current_lon: lon,
current_zoom: zoom,
grid_visible: false,
radar_visible: false
)}
end
@impl true
def handle_params(params, _uri, socket) do
selected = pick_layer(params["layer"], socket.assigns[:selected_layer])
band = pick_band(params["band"], selected)
# Update current viewport so subsequent push_patch URLs combine
# `layer=` with whatever lat/lon/zoom the user has navigated to.
# The map hook is `phx-update="ignore"` so re-rendering with new
# data attributes won't re-init Leaflet.
{lat, lon, zoom} = parse_viewport_params(params)
{:noreply,
socket
|> assign(:selected_layer, selected)
|> assign(:band_filter, band)
|> assign(:current_lat, lat)
|> assign(:current_lon, lon)
|> assign(:current_zoom, zoom)}
end
defp pick_layer(layer, current) when is_binary(layer) do
if MapLayers.valid_id?(layer), do: layer, else: pick_layer(nil, current)
end
defp pick_layer(_layer, current) when is_binary(current), do: current
defp pick_layer(_layer, _current), do: @default_layer
# Band filter only applies to the duct-cutoff layer; switching away
# from it drops the filter so a stale `?band=24` on the URL doesn't
# ghost-mask the next layer that lands on the same hook.
defp pick_band(band, "duct_cutoff_ghz") when is_binary(band) do
case Integer.parse(band) do
{n, ""} -> if n in @cutoff_bands, do: n
_ -> nil
end
end
defp pick_band(_band, _layer), do: nil
# Parse `lat` / `lon` / `zoom` query params, falling back to defaults
# when missing or out of range. Lat in [-90, 90], lon in [-180, 180],
# zoom in [@min_zoom, @max_zoom]. A bad single param doesn't poison
# the others — each clamps independently.
defp parse_viewport_params(params) do
lat = parse_float_param(params["lat"], -90.0, 90.0, @default_center_lat)
lon = parse_float_param(params["lon"], -180.0, 180.0, @default_center_lon)
zoom =
case Integer.parse(params["zoom"] || "") do
{z, ""} when z >= @min_zoom and z <= @max_zoom -> z
_ -> @default_zoom
end
{lat, lon, zoom}
end
defp parse_float_param(value, min, max, default) when is_binary(value) do
case Float.parse(value) do
{f, ""} when f >= min and f <= max -> f
_ -> default
end
end
defp parse_float_param(_value, _min, _max, default), do: default
@doc false
# Public for direct unit testing: anchoring an integration test on a
# truncated `utc_now()` would only assert the new behavior past minute
# 30 of the wall clock.
def pick_initial_valid_time(valid_times, now \\ DateTime.utc_now())
def pick_initial_valid_time([], _now), do: nil
def pick_initial_valid_time(valid_times, now) do
# Prefer the most recent valid_time that is <= now (the "current
# hour" — what HRRR analysis just delivered and what the HRDPS chain
# seeds when fired this hour). Fall back to the closest absolute
# match only when nothing on disk is yet at-or-before now.
past_or_equal =
Enum.filter(valid_times, fn vt -> DateTime.compare(vt, now) != :gt end)
case past_or_equal do
[] -> Enum.min_by(valid_times, fn vt -> abs(DateTime.diff(vt, now, :second)) end)
list -> Enum.max_by(list, &DateTime.to_unix/1)
end
end
# Drop valid_times older than one hour ago. The HRRR scalar cache can
# hold the previous run's forecast hours past their useful life;
# showing 3-hour-old "current conditions" on the map is more
# misleading than helpful.
defp recent_valid_times(valid_times) do
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
Enum.filter(valid_times, fn vt -> DateTime.compare(vt, cutoff) != :lt end)
end
# Build a `/weather?…` path that preserves the current
# layer + lat/lon/zoom. `overrides` lets a specific event (e.g.
# `select_layer`) tweak one field without losing the others.
defp build_path(socket, overrides \\ []) do
layer = Keyword.get(overrides, :layer, socket.assigns.selected_layer)
band = Keyword.get(overrides, :band, socket.assigns[:band_filter])
lat = Keyword.get(overrides, :lat, socket.assigns.current_lat)
lon = Keyword.get(overrides, :lon, socket.assigns.current_lon)
zoom = Keyword.get(overrides, :zoom, socket.assigns.current_zoom)
# List form (not map) so the param order is stable — tests
# asserting on the exact URL, and humans reading shareable links,
# both benefit from layer first then viewport.
base = [{"layer", layer}]
band_params = if layer == "duct_cutoff_ghz" and is_integer(band), do: [{"band", band}], else: []
query =
URI.encode_query(base ++ band_params ++ [{"lat", lat}, {"lon", lon}, {"zoom", zoom}])
"/weather?#{query}"
end
@impl true
def handle_event("select_layer", %{"layer" => layer_id}, socket) do
if MapLayers.valid_id?(layer_id) do
# Drop a lingering band filter when switching to a non-cutoff
# layer — keeps the URL tidy and avoids ghost-mask state.
band = if layer_id == "duct_cutoff_ghz", do: socket.assigns[:band_filter]
{:noreply, push_patch(socket, to: build_path(socket, layer: layer_id, band: band))}
else
{:noreply, socket}
end
end
# Click the active band again to reset to "All" (continuous overview).
def handle_event("select_band_filter", %{"band" => band_str}, socket) do
band =
case Integer.parse(band_str || "") do
{n, ""} -> if n in @cutoff_bands, do: n
_ -> nil
end
next = if band == socket.assigns[:band_filter], do: nil, else: band
socket =
socket
|> assign(:band_filter, next)
|> push_event("set_band_filter", %{band: next})
{:noreply, push_patch(socket, to: build_path(socket, band: next))}
end
def handle_event("clear_band_filter", _params, socket) do
socket =
socket
|> assign(:band_filter, nil)
|> push_event("set_band_filter", %{band: nil})
{:noreply, push_patch(socket, to: build_path(socket, band: nil))}
end
# Debounced moveend/zoomend from the JS hook. Every viewport change
# patches the URL so a refresh / share-link lands the user back at
# the same view. Stays out of the LiveView assigns hot path —
# `phx-update="ignore"` on the map div means the hook isn't re-run.
def handle_event("viewport_changed", %{"lat" => lat, "lon" => lon, "zoom" => zoom}, socket)
when is_number(lat) and is_number(lon) and is_number(zoom) do
new_lat = lat |> max(-90.0) |> min(90.0) |> Float.round(4)
new_lon = lon |> max(-180.0) |> min(180.0) |> Float.round(4)
new_zoom = zoom |> max(@min_zoom) |> min(@max_zoom) |> trunc()
socket =
socket
|> assign(:current_lat, new_lat)
|> assign(:current_lon, new_lon)
|> assign(:current_zoom, new_zoom)
{:noreply, push_patch(socket, to: build_path(socket), replace: true)}
end
def handle_event("viewport_changed", _params, socket), do: {:noreply, socket}
def handle_event("toggle_grid", _params, socket) do
visible = !socket.assigns.grid_visible
socket =
socket
|> assign(:grid_visible, visible)
|> push_event("toggle_grid", %{visible: visible})
{:noreply, socket}
end
def handle_event("toggle_radar", _params, socket) do
visible = !socket.assigns.radar_visible
socket =
socket
|> assign(:radar_visible, visible)
|> push_event("toggle_radar", %{visible: visible})
{:noreply, socket}
end
def handle_event("select_time", %{"time" => time_str}, socket) do
with {:ok, time, _} <- DateTime.from_iso8601(time_str),
true <- time in socket.assigns.valid_times do
socket =
socket
|> assign(:valid_time, time)
|> push_event("update_weather_overlay", %{selected: DateTime.to_iso8601(time)})
{:noreply, socket}
else
_ -> {:noreply, socket}
end
end
# `/map`'s PropagationMap hook pushes `map_bounds` on moveend/zoomend.
# When a user navigates /map → /weather, an in-flight bounds push from
# the old hook can land on the new WeatherMapLive (LV reuses sockets
# across live navigation). WeatherMapLive doesn't need bounds — the
# /weather/cells HTTP endpoint computes them client-side — so swallow
# the event instead of crashing the LV process.
def handle_event("map_bounds", _params, socket), do: {:noreply, socket}
def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
detail =
if socket.assigns.valid_time do
Weather.weather_point_detail(lat, lon, socket.assigns.valid_time)
end
payload = detail || %{}
{:noreply, push_event(socket, "point_detail", payload)}
end
@impl true
def handle_info({:weather_updated, _valid_time}, socket) do
valid_times = recent_valid_times(Weather.available_weather_valid_times())
# Keep whatever the user has scrubbed to if it's still available,
# otherwise fall back to the latest on-disk time (GridCache-backed
# hot path). Never block mount/refresh on a ProfilesFile read for
# the happy path.
selected =
cond do
socket.assigns.valid_time in valid_times -> socket.assigns.valid_time
valid_times != [] -> List.last(valid_times)
true -> nil
end
socket =
socket
|> assign(:valid_times, valid_times)
|> assign(:valid_time, selected)
|> push_event("update_weather_overlay", %{selected: selected && DateTime.to_iso8601(selected)})
|> push_event("update_timeline", %{
times: Enum.map(valid_times, &DateTime.to_iso8601/1),
selected: selected && DateTime.to_iso8601(selected)
})
{:noreply, socket}
end
# A new forecast hour has landed. Re-enumerate the available times
# so the timeline picks up the extra hour, but keep the currently
# selected time where it is.
def handle_info({:propagation_pipeline_progress, _progress}, socket) do
valid_times = recent_valid_times(Weather.available_weather_valid_times())
if valid_times == socket.assigns.valid_times do
{:noreply, socket}
else
socket =
socket
|> assign(:valid_times, valid_times)
|> push_event("update_timeline", %{
times: Enum.map(valid_times, &DateTime.to_iso8601/1),
selected: socket.assigns.valid_time && DateTime.to_iso8601(socket.assigns.valid_time)
})
{:noreply, socket}
end
end
@group_order ["Surface", "Upper Air", "Ducting"]
defp group_layers(layers) do
layers
|> Enum.group_by(& &1.group)
|> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end)
end
defp layer_description(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{desc: desc} -> desc
_ -> nil
end
end
defp selected_layer_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{label: label} -> label
_ -> ""
end
end
defp selected_layer_group_label(layers, selected_id) do
case Enum.find(layers, &(&1.id == selected_id)) do
%{group: group} -> group
_ -> ""
end
end
attr :bands, :list, required: true
attr :selected, :any, default: nil
defp cutoff_band_picker(assigns) do
~H"""
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<div class="text-[10px] font-semibold opacity-60 uppercase tracking-wider px-1">
Filter to band
</div>
<div class="flex flex-wrap gap-1">
<button
phx-click="clear_band_filter"
class={[
"btn btn-xs rounded-full",
if(@selected == nil, do: "btn-primary", else: "btn-ghost")
]}
>
All
</button>
<button
:for={band <- @bands}
phx-click="select_band_filter"
phx-value-band={band}
class={[
"btn btn-xs rounded-full font-mono tabular-nums",
if(@selected == band, do: "btn-primary", else: "btn-ghost")
]}
>
{band}G
</button>
</div>
</div>
"""
end
@impl true
def render(assigns) do
~H"""
<div class="flex w-screen h-screen overflow-hidden">
<%!-- Map container --%>
<div class="relative flex-1 min-w-0">
<%!-- Full-page map --%>
<div
id="weather-map"
phx-hook="WeatherMap"
phx-update="ignore"
data-layers={Jason.encode!(@layers)}
data-selected-layer={@selected_layer}
data-band-filter={@band_filter}
data-valid-times={@initial_valid_times_json}
data-selected-time={@initial_selected_time}
data-initial-lat={@initial_center_lat}
data-initial-lon={@initial_center_lon}
data-initial-zoom={@initial_zoom}
class="absolute inset-0 z-0"
>
</div>
<%!-- Bottom forecast timeline --%>
<div
id="weather-forecast-timeline"
phx-update="ignore"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
>
</div>
<%!-- Mobile-only floating controls --%>
<div
id="weather-controls"
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
>
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-mobile"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-mobile-panel-toggle"
class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('weather-panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
</div>
<div id="weather-panel-extras" class="hidden flex-col gap-2">
<%!-- Layer pill buttons grouped --%>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<div class="text-[11px] opacity-70 px-1 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
<.cutoff_band_picker
:if={@selected_layer == "duct_cutoff_ghz"}
bands={@cutoff_bands}
selected={@band_filter}
/>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
Propagation Map
</.link>
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator
</.link>
<.link navigate="/eme" class="btn btn-xs btn-ghost justify-start">
EME
</.link>
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Beacons
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
Contact Map
</.link>
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
Scoring Algorithm
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
</div>
<%!-- Point detail panel --%>
<div
id="weather-detail-panel"
phx-update="ignore"
class={[
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
"md:absolute md:top-auto md:right-auto md:bottom-4 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
]}
style="display:none;"
>
</div>
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
<button
id="weather-sidebar-expand"
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
onclick="document.getElementById('weather-sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
<%!-- Desktop right sidebar --%>
<div
id="weather-sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
>
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Weather Map</div>
<div class="font-normal text-[10px] opacity-60">
<%= if @valid_time do %>
{Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
<% else %>
No data available
<% end %>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="weather-utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="weather-sidebar-collapse"
class="btn btn-xs btn-square btn-ghost"
title="Collapse sidebar"
onclick="document.getElementById('weather-sidebar').style.display='none';document.getElementById('weather-sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-chevron-right" class="size-4" />
</button>
</div>
</div>
<%!-- Layer pill buttons grouped --%>
<div class="flex flex-col gap-1.5">
<div :for={{group, layers} <- group_layers(@layers)}>
<div class="text-[10px] font-semibold opacity-50 uppercase tracking-wider px-1 mb-0.5">
{group}
</div>
<div class="flex flex-wrap gap-1">
<button
:for={layer <- layers}
phx-click="select_layer"
phx-value-layer={layer.id}
class={[
"btn btn-xs rounded-full",
if(@selected_layer == layer.id, do: "btn-primary", else: "btn-ghost")
]}
>
{layer.label}
</button>
</div>
</div>
</div>
<%!-- Layer description (group · name header + body) --%>
<div class="px-1">
<div class="text-[10px] font-semibold uppercase tracking-wider opacity-60 mb-1">
{selected_layer_group_label(@layers, @selected_layer)} &middot; {selected_layer_label(
@layers,
@selected_layer
)}
</div>
<div class="text-[11px] opacity-70 leading-snug">
{layer_description(@layers, @selected_layer)}
</div>
</div>
<.cutoff_band_picker
:if={@selected_layer == "duct_cutoff_ghz"}
bands={@cutoff_bands}
selected={@band_filter}
/>
<%!-- Overlay toggles --%>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@radar_visible}
phx-click="toggle_radar"
/>
<span class="text-sm">Weather radar</span>
</label>
</div>
<%!-- Navigation --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<li>
<.link navigate="/map">Propagation Map</.link>
</li>
<li>
<.link navigate="/path">Path Calculator</.link>
</li>
<li>
<.link navigate="/eme">EME</.link>
</li>
<li>
<.link navigate="/beacons">Beacons</.link>
</li>
<li>
<.link navigate="/submit">Submit a Contact</.link>
</li>
<li>
<.link navigate="/contacts">Contact Training Data</.link>
</li>
<li>
<.link navigate="/contacts/map">Contact Map</.link>
</li>
<li>
<.link navigate="/algo">Scoring Algorithm</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li>
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
{@current_scope.user.callsign}
</.link>
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
end
end