From fc245367e32c1aa8e98b304e7b3be5ca367c5f53 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 12 Apr 2026 16:13:07 -0500 Subject: [PATCH] User profiles at /u/:callsign, flexible band input, assorted UX cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop/accounts.ex | 23 +++ lib/microwaveprop/beacons.ex | 13 ++ lib/microwaveprop/radio.ex | 41 ++++- lib/microwaveprop/radio/adif_import.ex | 47 +---- lib/microwaveprop/radio/band_resolver.ex | 102 +++++++++++ lib/microwaveprop/radio/contact.ex | 4 +- lib/microwaveprop/radio/contact_edit.ex | 2 +- lib/microwaveprop/radio/csv_import.ex | 26 ++- lib/microwaveprop_web/components/layouts.ex | 7 +- .../live/beacon_live/index.ex | 5 - .../live/contact_live/index.ex | 9 +- .../live/contact_map_live.ex | 6 +- lib/microwaveprop_web/live/map_live.ex | 6 +- .../live/user_profile_live.ex | 170 ++++++++++++++++++ .../live/weather_map_live.ex | 6 +- lib/microwaveprop_web/router.ex | 3 + test/microwaveprop/accounts_test.exs | 21 +++ test/microwaveprop/beacons_test.exs | 21 +++ test/microwaveprop/radio/adif_import_test.exs | 20 +++ test/microwaveprop/radio/csv_import_test.exs | 6 +- test/microwaveprop/radio_test.exs | 46 +++++ .../live/contact_live_test.exs | 26 --- .../live/user_profile_live_test.exs | 95 ++++++++++ 23 files changed, 611 insertions(+), 94 deletions(-) create mode 100644 lib/microwaveprop/radio/band_resolver.ex create mode 100644 lib/microwaveprop_web/live/user_profile_live.ex create mode 100644 test/microwaveprop_web/live/user_profile_live_test.exs diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex index 69400fdf..a29b63fb 100644 --- a/lib/microwaveprop/accounts.ex +++ b/lib/microwaveprop/accounts.ex @@ -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. diff --git a/lib/microwaveprop/beacons.ex b/lib/microwaveprop/beacons.ex index 56571333..91cfdbb9 100644 --- a/lib/microwaveprop/beacons.ex +++ b/lib/microwaveprop/beacons.ex @@ -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) diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 77e7e02c..42f5fc52 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -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) diff --git a/lib/microwaveprop/radio/adif_import.ex b/lib/microwaveprop/radio/adif_import.ex index 0b7906ec..cd030b5e 100644 --- a/lib/microwaveprop/radio/adif_import.ex +++ b/lib/microwaveprop/radio/adif_import.ex @@ -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 diff --git a/lib/microwaveprop/radio/band_resolver.ex b/lib/microwaveprop/radio/band_resolver.ex new file mode 100644 index 00000000..02f5310e --- /dev/null +++ b/lib/microwaveprop/radio/band_resolver.ex @@ -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 diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index c8614e95..92141959 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -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 diff --git a/lib/microwaveprop/radio/contact_edit.ex b/lib/microwaveprop/radio/contact_edit.ex index 796a2576..bd035c2f 100644 --- a/lib/microwaveprop/radio/contact_edit.ex +++ b/lib/microwaveprop/radio/contact_edit.ex @@ -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 diff --git a/lib/microwaveprop/radio/csv_import.ex b/lib/microwaveprop/radio/csv_import.ex index e23df1d4..124f6494 100644 --- a/lib/microwaveprop/radio/csv_import.ex +++ b/lib/microwaveprop/radio/csv_import.ex @@ -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 diff --git a/lib/microwaveprop_web/components/layouts.ex b/lib/microwaveprop_web/components/layouts.ex index 9583e5e5..ab737a95 100644 --- a/lib/microwaveprop_web/components/layouts.ex +++ b/lib/microwaveprop_web/components/layouts.ex @@ -69,9 +69,12 @@ defmodule MicrowavepropWeb.Layouts do Oban <%= if @current_scope && @current_scope.user do %> - + <.link + navigate={~p"/u/#{@current_scope.user.callsign}"} + class="btn btn-ghost btn-sm" + > {@current_scope.user.callsign} - + <.link href={~p"/users/settings"} class="btn btn-ghost btn-sm">Settings <.link href={~p"/users/log-out"} method="delete" class="btn btn-ghost btn-sm"> Log out diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex index 16c57952..7a4fee42 100644 --- a/lib/microwaveprop_web/live/beacon_live/index.ex +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -29,8 +29,6 @@ defmodule MicrowavepropWeb.BeaconLive.Index do <:col :let={{_id, beacon}} label="Call">{beacon.callsign} <:col :let={{_id, beacon}} label="Grid">{beacon.grid} - <:col :let={{_id, beacon}} label="Lat">{format_coord(beacon.lat)} - <:col :let={{_id, beacon}} label="Lon">{format_coord(beacon.lon)} <:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)} <:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft} <:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)} @@ -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 diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex index fb948c90..6590b19b 100644 --- a/lib/microwaveprop_web/live/contact_live/index.ex +++ b/lib/microwaveprop_web/live/contact_live/index.ex @@ -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 Band Mode Distance (km) - Submitted Enriched Timestamp (UTC) @@ -162,7 +157,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do <%= for {primary, reciprocals} <- @grouped_contacts do %> - + {primary.band} {primary.mode} {primary.distance_km} - {submitted_cell(primary)} {enrichment_badge(primary)} {if primary.flagged_invalid, do: "🚩"} {Calendar.strftime(primary.qso_timestamp, "%Y-%m-%d %H:%M")} @@ -206,7 +200,6 @@ defmodule MicrowavepropWeb.ContactLive.Index do {recip.band} {recip.mode} {recip.distance_km} - {submitted_cell(recip)} {enrichment_badge(recip)} {if recip.flagged_invalid, do: "🚩"} {Calendar.strftime(recip.qso_timestamp, "%Y-%m-%d %H:%M")} diff --git a/lib/microwaveprop_web/live/contact_map_live.ex b/lib/microwaveprop_web/live/contact_map_live.ex index 18e716b1..398ddff8 100644 --- a/lib/microwaveprop_web/live/contact_map_live.ex +++ b/lib/microwaveprop_web/live/contact_map_live.ex @@ -269,7 +269,11 @@ defmodule MicrowavepropWeb.ContactMapLive do <%!-- Auth --%>