User profiles at /u/:callsign, flexible band input, assorted UX cleanup

Profile page:
  * New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
    page showing a user's contacts and beacons. Resolves case-
    insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
    callsigns redirect to /. Uses daisyUI card / stats / table
    components with an avatar-placeholder initial and hero icons.
  * Accounts.get_user_by_callsign/1 (case-insensitive) plus
    Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
    back the page. Beacons list includes both approved and pending so
    owners see their drafts.
  * The top nav bar and the three LiveView sidebars (MapLive,
    WeatherMapLive, ContactMapLive) now render the logged-in callsign
    as a navigate link to /u/:callsign instead of a static label.
  * Nine new tests cover the lookup, the LiveView render, and the
    ownership-scoped queries.

Flexible band input:
  * New Microwaveprop.Radio.BandResolver module converts any of:
    ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
    insensitive), numeric frequency strings ("903.100", "10368.000"),
    and canonical MHz integers into the one of the site's known bands.
    Returns the nearest allowed band for numeric inputs >= 900 MHz,
    nil otherwise.
  * 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
    AdifImport.@allowed_bands, and the BandResolver list so "33cm"
    round-trips end-to-end.
  * AdifImport and CsvImport now delegate band resolution to
    BandResolver, and Radio.create_contact/2 normalizes the :band attr
    on the way in so the manual form and any API callers benefit too.
    CsvImport's "invalid band" tests previously used 99999 MHz which
    the new resolver snaps to the nearest allowed band; swapped to
    "notaband" which is truly unresolvable.

Contacts and beacons list UX:
  * Remove the "Submitted" column from /contacts — it duplicated info
    already visible on the detail page and was pushing the real
    columns off narrow viewports. submitted_cell/1 and its three
    column-specific tests go with it.
  * Hide the Lat / Lon columns from /beacons — six decimal places of
    coordinates weren't useful next to the grid square and took a
    disproportionate amount of row width.
This commit is contained in:
Graham McIntire 2026-04-12 16:13:07 -05:00
parent 4ce7b21b3b
commit fc245367e3
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
23 changed files with 611 additions and 94 deletions

View file

@ -28,6 +28,29 @@ defmodule Microwaveprop.Accounts do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by their amateur callsign. The lookup is case-insensitive
so `/u/w5isp` and `/u/W5ISP` resolve to the same profile.
Returns `nil` for unknown callsigns and for `nil`/empty input.
## Examples
iex> get_user_by_callsign("W5ISP")
%User{}
iex> get_user_by_callsign("unknown")
nil
"""
@spec get_user_by_callsign(String.t() | nil) :: User.t() | nil
def get_user_by_callsign(nil), do: nil
def get_user_by_callsign(""), do: nil
def get_user_by_callsign(callsign) when is_binary(callsign) do
Repo.get_by(User, callsign: String.upcase(callsign))
end
@doc """
Gets a user by email and password.

View file

@ -49,6 +49,19 @@ defmodule Microwaveprop.Beacons do
)
end
@doc """
Returns every beacon (approved or pending) that the given user submitted,
newest first. Used by the public `/u/:callsign` profile page.
"""
@spec list_beacons_for_user(User.t()) :: [Beacon.t()]
def list_beacons_for_user(%User{id: user_id}) do
Repo.all(
from b in Beacon,
where: b.user_id == ^user_id,
order_by: [desc: b.inserted_at]
)
end
@doc "Gets a single beacon. Raises if not found."
@spec get_beacon!(Ecto.UUID.t()) :: Beacon.t()
def get_beacon!(id), do: Beacon |> Repo.get!(id) |> Repo.preload(:user)

View file

@ -5,6 +5,7 @@ defmodule Microwaveprop.Radio do
alias Microwaveprop.Accounts.User
alias Microwaveprop.Cache
alias Microwaveprop.Radio.BandResolver
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ContactEdit
alias Microwaveprop.Radio.Maidenhead
@ -114,6 +115,19 @@ defmodule Microwaveprop.Radio do
end)
end
@doc """
Returns all contacts submitted by the given user, newest first. Used
by the public `/u/:callsign` profile page; expected to return a small
list since only logged-in submissions carry a `user_id`.
"""
@spec list_contacts_for_user(User.t()) :: [Contact.t()]
def list_contacts_for_user(%User{id: user_id}) do
Contact
|> where([c], c.user_id == ^user_id)
|> order_by([c], desc: c.qso_timestamp, desc: c.id)
|> Repo.all()
end
@spec list_contacts(keyword()) :: contact_page()
def list_contacts(opts \\ []) do
page = max(Keyword.get(opts, :page, 1), 1)
@ -498,7 +512,7 @@ defmodule Microwaveprop.Radio do
def create_contact(attrs, user_id \\ nil) do
changeset =
%Contact{}
|> Contact.submission_changeset(attrs)
|> Contact.submission_changeset(normalize_band_attr(attrs))
|> maybe_put_user_id(user_id)
if changeset.valid? do
@ -508,6 +522,31 @@ defmodule Microwaveprop.Radio do
end
end
# Translate ADIF wavelength labels ("33cm") and raw frequency strings
# ("903.100") into our canonical MHz integer string so every create
# path — manual form, CSV commit, ADIF commit, API, console — agrees
# on what "33cm" means before the changeset's validate_inclusion runs.
# Atom and string keys are both supported so this works regardless of
# where the map came from.
defp normalize_band_attr(attrs) do
cond do
raw = Map.get(attrs, "band") ->
case BandResolver.resolve_as_string(raw) do
nil -> attrs
canonical -> Map.put(attrs, "band", canonical)
end
raw = Map.get(attrs, :band) ->
case BandResolver.resolve_as_string(raw) do
nil -> attrs
canonical -> Map.put(attrs, :band, canonical)
end
true ->
attrs
end
end
defp maybe_put_user_id(changeset, nil), do: changeset
defp maybe_put_user_id(changeset, user_id), do: Ecto.Changeset.put_change(changeset, :user_id, user_id)

View file

@ -9,29 +9,12 @@ defmodule Microwaveprop.Radio.AdifImport do
import Ecto.Query, only: [from: 2]
alias Microwaveprop.Radio.BandResolver
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@dedup_window_seconds 3600
# Band plan: map ADIF band names to our MHz values
@band_name_to_mhz %{
"23cm" => 1296,
"13cm" => 2304,
"9cm" => 3456,
"6cm" => 5760,
"3cm" => 10_000,
"1.25cm" => 24_000,
"6mm" => 47_000,
"4mm" => 75_000,
"2.5mm" => 122_000,
"2mm" => 134_000,
"1mm" => 241_000
}
# Our allowed band centers in MHz, sorted ascending for nearest-match
@allowed_bands [1296, 2304, 3456, 5760, 10_000, 24_000, 47_000, 68_000, 75_000, 122_000, 134_000, 241_000]
# Map ADIF MODE (and MODE+SUBMODE combinations) onto the fixed set of modes
# the site accepts: CW / SSB / FM / FT8 / FT4 / Q65. Unknown values map to
# nil — mode is optional on submission so unrecognized contacts still
@ -252,34 +235,12 @@ defmodule Microwaveprop.Radio.AdifImport do
defp resolve_band(fields) do
cond do
freq = fields["FREQ"] ->
freq_mhz = parse_freq_mhz(freq)
nearest_band(freq_mhz)
band_name = fields["BAND"] ->
band_name = String.downcase(band_name)
mhz = @band_name_to_mhz[band_name]
if mhz, do: to_string(mhz)
true ->
nil
freq = fields["FREQ"] -> BandResolver.resolve_as_string(freq)
band_name = fields["BAND"] -> BandResolver.resolve_as_string(band_name)
true -> nil
end
end
defp parse_freq_mhz(freq_str) do
{freq, _} = Float.parse(String.trim(freq_str))
# ADIF FREQ is always in MHz (e.g., 10368.000 for 10 GHz band)
freq
end
defp nearest_band(freq_mhz) when freq_mhz >= 900 do
@allowed_bands
|> Enum.min_by(&abs(&1 - freq_mhz))
|> to_string()
end
defp nearest_band(_freq_mhz), do: nil
# -- timestamp parsing -----------------------------------------------------
defp parse_adif_datetime(nil, _time), do: nil

View file

@ -0,0 +1,102 @@
defmodule Microwaveprop.Radio.BandResolver do
@moduledoc """
Converts user-supplied band input ADIF wavelength labels ("33cm",
"13cm"), numeric frequencies in MHz ("903.100", "10368"), or bare
channel numbers ("902", "1296") into one of the site's canonical
MHz values.
Used by `Microwaveprop.Radio.AdifImport`, `Microwaveprop.Radio.CsvImport`,
and the `SubmitLive` manual form so all four entry points agree on
what "33cm" means.
"""
# Keep in sync with:
# * `Microwaveprop.Radio.Contact.@allowed_bands`
# * `Microwaveprop.Propagation.BandConfig.@band_configs`
@allowed_bands [902, 1296, 2304, 3456, 5760, 10_000, 24_000, 47_000, 68_000, 75_000, 122_000, 134_000, 241_000]
# ADIF amateur wavelength band labels → our MHz channel centers.
# The `normalize/1` helper strips whitespace and lower-cases before
# looking up, so "33 CM", "33cm", " 33Cm " all resolve.
@band_name_to_mhz %{
"33cm" => 902,
"23cm" => 1_296,
"13cm" => 2_304,
"9cm" => 3_456,
"6cm" => 5_760,
"3cm" => 10_000,
"1.25cm" => 24_000,
"6mm" => 47_000,
"4mm" => 75_000,
"2.5mm" => 122_000,
"2mm" => 134_000,
"1mm" => 241_000
}
@doc "All MHz band centers the site accepts, ascending."
@spec allowed_bands() :: [pos_integer()]
def allowed_bands, do: @allowed_bands
@doc """
Resolve any user-supplied band input to a canonical MHz integer.
Accepts:
* ADIF wavelength labels ("33cm", "1.25cm", "6mm" case/whitespace
insensitive)
* numeric frequencies in MHz as a string or number ("903.100",
10368, "10368.000") returns the nearest allowed band
* pre-canonicalized band values (902, "902") passes through
Returns `nil` for unparseable input and for sub-900 MHz values (which
are below the site's lowest supported microwave band).
"""
@spec resolve(term()) :: pos_integer() | nil
def resolve(nil), do: nil
def resolve(""), do: nil
def resolve(mhz) when is_integer(mhz), do: nearest_band(mhz * 1.0)
def resolve(mhz) when is_float(mhz), do: nearest_band(mhz)
def resolve(value) when is_binary(value) do
normalized = normalize(value)
cond do
mhz = @band_name_to_mhz[normalized] -> mhz
freq = parse_float(normalized) -> nearest_band(freq)
true -> nil
end
end
def resolve(_), do: nil
@doc "Return `resolve/1` as a string, or `nil`."
@spec resolve_as_string(term()) :: String.t() | nil
def resolve_as_string(value) do
case resolve(value) do
nil -> nil
mhz -> Integer.to_string(mhz)
end
end
defp normalize(s) do
s
|> String.trim()
|> String.downcase()
|> String.replace(~r/\s+/, "")
end
defp parse_float(s) do
case Float.parse(s) do
{f, ""} -> f
{f, _rest} -> f
:error -> nil
end
end
defp nearest_band(freq_mhz) when freq_mhz >= 900 do
Enum.min_by(@allowed_bands, &abs(&1 - freq_mhz))
end
defp nearest_band(_), do: nil
end

View file

@ -62,7 +62,9 @@ defmodule Microwaveprop.Radio.Contact do
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email)a
@submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1)
# Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and
# Microwaveprop.Radio.AdifImport.@allowed_bands.
@allowed_bands Enum.map(~w(902 1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1)
@spec submission_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def submission_changeset(contact, attrs) do

View file

@ -12,7 +12,7 @@ defmodule Microwaveprop.Radio.ContactEdit do
@foreign_key_type :binary_id
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
@allowed_bands ~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000)
@allowed_bands ~w(902 1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000)
schema "contact_edits" do
belongs_to :contact, Contact

View file

@ -23,6 +23,7 @@ defmodule Microwaveprop.Radio.CsvImport do
import Ecto.Query, only: [from: 2]
alias Microwaveprop.Radio
alias Microwaveprop.Radio.BandResolver
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
@ -190,7 +191,11 @@ defmodule Microwaveprop.Radio.CsvImport do
end
defp validate_parsed_row(attrs, row_num, iso_timestamp) do
attrs = Map.put(attrs, "qso_timestamp", iso_timestamp)
attrs =
attrs
|> Map.put("qso_timestamp", iso_timestamp)
|> normalize_band_attr()
changeset = Contact.submission_changeset(%Contact{}, attrs)
if changeset.valid? do
@ -201,6 +206,25 @@ defmodule Microwaveprop.Radio.CsvImport do
end
end
# CSV users may type ADIF wavelength labels ("33cm"), bare frequencies
# ("903.100"), or canonical band numbers ("902"). Run the supplied value
# through BandResolver so any of those forms end up as an integer MHz
# string that Contact.submission_changeset's validate_inclusion accepts.
# If the input is unresolvable we leave it alone so the user gets a
# specific "band is invalid" changeset error instead of a silent drop.
defp normalize_band_attr(attrs) do
case attrs["band"] do
nil ->
attrs
raw ->
case BandResolver.resolve_as_string(raw) do
nil -> attrs
canonical -> Map.put(attrs, "band", canonical)
end
end
end
defp split_parsed(rows) do
rows
|> Enum.reduce({[], []}, fn

View file

@ -69,9 +69,12 @@ defmodule MicrowavepropWeb.Layouts do
Oban
</.link>
<%= if @current_scope && @current_scope.user do %>
<span class="btn btn-ghost btn-sm pointer-events-none opacity-70">
<.link
navigate={~p"/u/#{@current_scope.user.callsign}"}
class="btn btn-ghost btn-sm"
>
{@current_scope.user.callsign}
</span>
</.link>
<.link href={~p"/users/settings"} class="btn btn-ghost btn-sm">Settings</.link>
<.link href={~p"/users/log-out"} method="delete" class="btn btn-ghost btn-sm">
Log out

View file

@ -29,8 +29,6 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
</:col>
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="Lat">{format_coord(beacon.lat)}</:col>
<:col :let={{_id, beacon}} label="Lon">{format_coord(beacon.lon)}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
<:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)}</:col>
@ -166,7 +164,4 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
defp admin?(%{user: %{is_admin: true}}), do: true
defp admin?(_), do: false
defp format_coord(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 6)
defp format_coord(value), do: to_string(value)
end

View file

@ -80,10 +80,6 @@ defmodule MicrowavepropWeb.ContactLive.Index do
]
@done_statuses [:complete, :unavailable]
defp submitted_cell(%{user: %{callsign: callsign}}) when is_binary(callsign), do: callsign
defp submitted_cell(%{user_submitted: true}), do: "Yes"
defp submitted_cell(_), do: ""
defp enrichment_badge(contact) do
details =
Enum.map(@enrichment_fields, fn {field, name} ->
@ -152,7 +148,6 @@ defmodule MicrowavepropWeb.ContactLive.Index do
<th>Band</th>
<th>Mode</th>
<th>Distance (km)</th>
<th>Submitted</th>
<th>Enriched</th>
<th></th>
<th>Timestamp (UTC)</th>
@ -162,7 +157,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
<tbody>
<%= for {primary, reciprocals} <- @grouped_contacts do %>
<tr class="h-1">
<td colspan="13"></td>
<td colspan="12"></td>
</tr>
<tr
class={[
@ -187,7 +182,6 @@ defmodule MicrowavepropWeb.ContactLive.Index do
<td>{primary.band}</td>
<td>{primary.mode}</td>
<td>{primary.distance_km}</td>
<td>{submitted_cell(primary)}</td>
<td>{enrichment_badge(primary)}</td>
<td class="text-center">{if primary.flagged_invalid, do: "🚩"}</td>
<td>{Calendar.strftime(primary.qso_timestamp, "%Y-%m-%d %H:%M")}</td>
@ -206,7 +200,6 @@ defmodule MicrowavepropWeb.ContactLive.Index do
<td>{recip.band}</td>
<td>{recip.mode}</td>
<td>{recip.distance_km}</td>
<td>{submitted_cell(recip)}</td>
<td>{enrichment_badge(recip)}</td>
<td class="text-center">{if recip.flagged_invalid, do: "🚩"}</td>
<td>{Calendar.strftime(recip.qso_timestamp, "%Y-%m-%d %H:%M")}</td>

View file

@ -269,7 +269,11 @@ defmodule MicrowavepropWeb.ContactMapLive do
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
<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>

View file

@ -651,7 +651,11 @@ defmodule MicrowavepropWeb.MapLive do
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
<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>

View file

@ -0,0 +1,170 @@
defmodule MicrowavepropWeb.UserProfileLive do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Radio
@impl true
def mount(%{"callsign" => raw_callsign}, _session, socket) do
case Accounts.get_user_by_callsign(raw_callsign) do
nil ->
{:ok,
socket
|> put_flash(:error, "No profile for #{raw_callsign}.")
|> push_navigate(to: ~p"/")}
user ->
contacts = Radio.list_contacts_for_user(user)
beacons = Beacons.list_beacons_for_user(user)
{:ok,
assign(socket,
page_title: user.callsign,
profile: user,
contacts: contacts,
beacons: beacons
)}
end
end
defp initial(%{name: name}) when is_binary(name) and name != "" do
name |> String.trim() |> String.first() |> String.upcase()
end
defp initial(%{callsign: callsign}) when is_binary(callsign), do: callsign |> String.first() |> String.upcase()
defp initial(_), do: "?"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
<div class="card bg-base-200 shadow-sm mb-6">
<div class="card-body flex-row items-center gap-4 py-5">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-16">
<span class="text-2xl font-mono">{initial(@profile)}</span>
</div>
</div>
<div class="flex-1 min-w-0">
<h1 class="card-title font-mono text-2xl">{@profile.callsign}</h1>
<p class="text-sm opacity-70">
<span :if={@profile.name}>{@profile.name} &middot;</span>
Member since {Calendar.strftime(@profile.inserted_at, "%B %Y")}
</p>
</div>
</div>
</div>
<div class="stats bg-base-200 shadow-sm mb-6 w-full">
<div class="stat">
<div class="stat-figure text-primary">
<.icon name="hero-radio" class="w-8 h-8" />
</div>
<div class="stat-title">Contacts submitted</div>
<div class="stat-value text-primary">{length(@contacts)}</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<.icon name="hero-signal" class="w-8 h-8" />
</div>
<div class="stat-title">Beacons submitted</div>
<div class="stat-value text-secondary">{length(@beacons)}</div>
</div>
</div>
<div class="card bg-base-200 shadow-sm mb-6">
<div class="card-body">
<h2 class="card-title">Contacts</h2>
<%= if @contacts == [] do %>
<div class="alert">
<.icon name="hero-information-circle" class="w-5 h-5" />
<span>No contacts submitted yet.</span>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>When</th>
<th>Stations</th>
<th>Band</th>
<th>Mode</th>
<th>Distance</th>
</tr>
</thead>
<tbody>
<tr :for={contact <- @contacts} class="hover">
<td class="whitespace-nowrap">
{Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
</td>
<td>
<.link navigate={~p"/contacts/#{contact.id}"} class="link link-hover font-mono">
{contact.station1} &harr; {contact.station2}
</.link>
</td>
<td>{contact.band && "#{contact.band} MHz"}</td>
<td>{contact.mode}</td>
<td>{contact.distance_km && "#{contact.distance_km} km"}</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
<div class="card bg-base-200 shadow-sm">
<div class="card-body">
<h2 class="card-title">Beacons</h2>
<%= if @beacons == [] do %>
<div class="alert">
<.icon name="hero-information-circle" class="w-5 h-5" />
<span>No beacons submitted yet.</span>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Callsign</th>
<th>Frequency</th>
<th>Grid</th>
<th>Keying</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr :for={beacon <- @beacons} class="hover">
<td>
<.link navigate={~p"/beacons/#{beacon.id}"} class="link link-hover font-mono">
{beacon.callsign}
</.link>
</td>
<td class="whitespace-nowrap">
{Beacon.format_freq(beacon.frequency_mhz)} MHz
</td>
<td>{beacon.grid}</td>
<td>{Beacon.keying_label(beacon.keying)}</td>
<td>
<div class={[
"badge badge-sm",
if(beacon.approved, do: "badge-success", else: "badge-warning")
]}>
{if beacon.approved, do: "Approved", else: "Pending"}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</Layouts.app>
"""
end
end

View file

@ -446,7 +446,11 @@ defmodule MicrowavepropWeb.WeatherMapLive do
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
<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>

View file

@ -85,6 +85,9 @@ defmodule MicrowavepropWeb.Router do
live "/beacons", BeaconLive.Index, :index
live "/beacons/new", BeaconLive.Form, :new
live "/beacons/:id", BeaconLive.Show, :show
# Public per-user profile (contributions: contacts + beacons submitted)
live "/u/:callsign", UserProfileLive
end
# Redirect old /qsos routes

View file

@ -18,6 +18,27 @@ defmodule Microwaveprop.AccountsTest do
end
end
describe "get_user_by_callsign/1" do
test "returns the user for an exact callsign match" do
%{id: id} = user = user_fixture(%{callsign: "W5ISP"})
assert %User{id: ^id} = Accounts.get_user_by_callsign(user.callsign)
end
test "is case-insensitive so /u/w5isp and /u/W5ISP both resolve" do
%{id: id} = user_fixture(%{callsign: "W5ISP"})
assert %User{id: ^id} = Accounts.get_user_by_callsign("w5isp")
end
test "returns nil when no user matches" do
refute Accounts.get_user_by_callsign("NO1SUCH")
end
test "returns nil for nil or empty callsign" do
refute Accounts.get_user_by_callsign(nil)
refute Accounts.get_user_by_callsign("")
end
end
describe "get_user_by_email_and_password/2" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")

View file

@ -132,6 +132,27 @@ defmodule Microwaveprop.BeaconsTest do
end
end
describe "list_beacons_for_user/1" do
test "returns both approved and pending beacons owned by the user" do
user = user_fixture()
other = user_fixture()
{:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs())
{:ok, approved} = Beacons.approve_beacon(approved)
pending = beacon_fixture(user, callsign: "W5PND")
_theirs = beacon_fixture(other, callsign: "W5THR")
ids = user |> Beacons.list_beacons_for_user() |> Enum.map(& &1.id) |> Enum.sort()
assert ids == Enum.sort([approved.id, pending.id])
end
test "returns an empty list when the user owns nothing" do
user = user_fixture()
assert Beacons.list_beacons_for_user(user) == []
end
end
describe "approve_beacon/1" do
test "marks the beacon as approved and broadcasts an update" do
user = user_fixture()

View file

@ -59,6 +59,26 @@ defmodule Microwaveprop.Radio.AdifImportTest do
assert row.attrs["band"] == "1296"
end
test "accepts 33cm ADIF band name and maps it to 902 MHz" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<BAND:4>33cm<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert [row] = preview.valid
assert row.attrs["band"] == "902"
end
test "maps a 902 MHz FREQ value onto the 902 band" do
adif = """
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:7>903.100<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
"""
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
assert [row] = preview.valid
assert row.attrs["band"] == "902"
end
test "maps ADIF frequency to correct band" do
for {freq, expected_band} <- [
{"1296.200", "1296"},

View file

@ -152,7 +152,7 @@ defmodule Microwaveprop.Radio.CsvImportTest do
end
test "reports invalid band" do
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,99999,CW,2026-03-28T18:00:00Z"
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z"
assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
assert errors != []
@ -194,7 +194,7 @@ defmodule Microwaveprop.Radio.CsvImportTest do
[
@valid_header,
@valid_row,
"W5XD,K5TR,EM12,EM00,99999,CW,2026-03-28T18:00:00Z",
"W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z",
"N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
],
"\n"
@ -470,7 +470,7 @@ defmodule Microwaveprop.Radio.CsvImportTest do
[
@valid_header,
"W5XD,K5TR,ZZZZ,EM00,10000,CW,2026-03-28T18:00:00Z",
"W5XD,K5TR,EM12,EM00,99999,CW,2026-03-28T18:00:00Z"
"W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z"
],
"\n"
)

View file

@ -1,6 +1,8 @@
defmodule Microwaveprop.RadioTest do
use Microwaveprop.DataCase, async: true
import Microwaveprop.AccountsFixtures
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
@ -452,4 +454,48 @@ defmodule Microwaveprop.RadioTest do
assert errors_on(changeset).grid1
end
end
describe "list_contacts_for_user/1" do
test "returns only contacts whose user_id matches the user" do
user = user_fixture()
other = user_fixture()
owned =
create_contact(%{station1: "W5OWN", user_id: user.id})
_their =
create_contact(%{station1: "W5THR", user_id: other.id})
_anon = create_contact(%{station1: "W5ANO"})
assert [found] = Radio.list_contacts_for_user(user)
assert found.id == owned.id
end
test "orders newest first" do
user = user_fixture()
_old =
create_contact(%{
qso_timestamp: ~U[2020-01-01 00:00:00Z],
user_id: user.id,
station1: "W5OLD"
})
new =
create_contact(%{
qso_timestamp: ~U[2026-06-15 12:00:00Z],
user_id: user.id,
station1: "W5NEW"
})
assert [first, _] = Radio.list_contacts_for_user(user)
assert first.id == new.id
end
test "returns an empty list when the user has submitted nothing" do
user = user_fixture()
assert Radio.list_contacts_for_user(user) == []
end
end
end

View file

@ -1,7 +1,6 @@
defmodule MicrowavepropWeb.ContactLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Microwaveprop.AccountsFixtures
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Contact
@ -116,31 +115,6 @@ defmodule MicrowavepropWeb.ContactLiveTest do
{:ok, _lv, html2} = live(conn, ~p"/contacts?page=2")
assert html2 =~ "Page 2 of 2"
end
test "Submitted column shows the user's callsign when linked to an account", %{conn: conn} do
user = user_fixture(%{callsign: "W5UNIQ"})
create_contact(%{station1: "K5ONE", user_submitted: true, user_id: user.id})
{:ok, _lv, html} = live(conn, ~p"/contacts?search=K5ONE")
assert html =~ "W5UNIQ"
end
test "Submitted column shows 'Yes' when user_submitted but no user linked", %{conn: conn} do
create_contact(%{station1: "K5ANON", user_submitted: true, user_id: nil})
{:ok, _lv, html} = live(conn, ~p"/contacts?search=K5ANON")
# Locate the row containing our station and assert "Yes" appears in it
[row] = Regex.run(~r/<tr[^>]*>(?:(?!<\/tr>).)*K5ANON(?:(?!<\/tr>).)*<\/tr>/s, html) || [""]
assert row =~ ">Yes<"
end
test "Submitted column shows dash for scraped contacts", %{conn: conn} do
create_contact(%{station1: "K5SCRAPE", user_submitted: false})
{:ok, _lv, html} = live(conn, ~p"/contacts?search=K5SCRAPE")
[row] = Regex.run(~r/<tr[^>]*>(?:(?!<\/tr>).)*K5SCRAPE(?:(?!<\/tr>).)*<\/tr>/s, html) || [""]
refute row =~ ">Yes<"
end
end
describe "Show" do

View file

@ -0,0 +1,95 @@
defmodule MicrowavepropWeb.UserProfileLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Microwaveprop.AccountsFixtures
import Microwaveprop.BeaconsFixtures
import Phoenix.LiveViewTest
alias Microwaveprop.Beacons
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
defp contact_for(user, attrs) do
defaults = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("10000"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295"),
user_id: user.id
}
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(defaults, attrs))
|> Repo.insert()
contact
end
describe "mount" do
test "renders a user's contributions", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
_contact = contact_for(user, %{station1: "W5OWN"})
_beacon = beacon_fixture(user, callsign: "W5ISP")
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "W5ISP"
assert html =~ "Contacts submitted"
assert html =~ "Beacons submitted"
assert html =~ "W5OWN"
end
test "callsign lookup is case-insensitive", %{conn: conn} do
_user = user_fixture(%{callsign: "W5ISP"})
{:ok, _lv, html} = live(conn, ~p"/u/w5isp")
assert html =~ "W5ISP"
end
test "does not show contacts belonging to another user", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
other = user_fixture(%{callsign: "W5OTH"})
_owned = contact_for(user, %{station1: "W5MINE"})
_theirs = contact_for(other, %{station1: "W5NOTMINE"})
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "W5MINE"
refute html =~ "W5NOTMINE"
end
test "shows empty-state messages when the user has no submissions", %{conn: conn} do
user = user_fixture(%{callsign: "W5EMP"})
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "No contacts submitted yet"
assert html =~ "No beacons submitted yet"
end
test "redirects to the home page for an unknown callsign", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/"}}} = live(conn, ~p"/u/NO1SUCH")
end
test "lists both approved and pending beacons so owners see their drafts", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
{:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs())
{:ok, _approved} = Beacons.approve_beacon(approved)
_pending = beacon_fixture(user, callsign: "W5PND")
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "W5PND"
assert html =~ "Pending"
assert html =~ "Approved"
end
end
end