prop/lib/microwaveprop/radio/band_resolver.ex
Graham McIntire fc245367e3
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.
2026-04-12 16:13:08 -05:00

102 lines
2.9 KiB
Elixir

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