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 --%>