prop/lib/microwaveprop/radio/contact.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

159 lines
5.1 KiB
Elixir

defmodule Microwaveprop.Radio.Contact do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "contacts" do
field :station1, :string
field :station2, :string
field :qso_timestamp, :utc_datetime
field :grid1, :string
field :grid2, :string
field :pos1, :map
field :pos2, :map
field :mode, :string
field :band, :decimal
field :distance_km, :decimal
field :hrrr_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
field :weather_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
field :terrain_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
field :iemre_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
field :user_submitted, :boolean, default: false
field :submitter_email, :string
field :flagged_invalid, :boolean, default: false
belongs_to :user, User
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(station1 station2 qso_timestamp band)a
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(contact, attrs) do
contact
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
end
@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)
# 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
contact
|> cast(attrs, @submission_fields)
|> validate_required(@submission_required)
|> validate_user_or_email()
|> sanitize_callsign(:station1)
|> sanitize_callsign(:station2)
|> sanitize_grid(:grid1)
|> sanitize_grid(:grid2)
|> normalize_blank_mode()
|> validate_callsign(:station1)
|> validate_callsign(:station2)
|> validate_grid_format(:grid1)
|> validate_grid_format(:grid2)
|> validate_inclusion(:mode, @allowed_modes)
|> validate_inclusion(:band, @allowed_bands)
|> validate_email_format()
|> validate_length(:submitter_email, max: 254)
|> validate_length(:station1, max: 20)
|> validate_length(:station2, max: 20)
end
# Treat empty / whitespace-only mode strings as "not provided" so the column
# ends up NULL instead of failing validate_inclusion. Mode is optional on the
# submission path.
defp normalize_blank_mode(changeset) do
case get_change(changeset, :mode) do
nil ->
changeset
value ->
case String.trim(value) do
"" -> put_change(changeset, :mode, nil)
trimmed -> put_change(changeset, :mode, trimmed)
end
end
end
# Either a user_id (logged-in submitter) or a submitter_email (anonymous)
# must be present so we know who submitted the contact.
defp validate_user_or_email(changeset) do
user_id = get_field(changeset, :user_id)
email = get_field(changeset, :submitter_email)
if user_id || (email && email != "") do
changeset
else
add_error(changeset, :submitter_email, "can't be blank")
end
end
defp validate_email_format(changeset) do
case get_field(changeset, :submitter_email) do
nil -> changeset
"" -> changeset
_email -> validate_format(changeset, :submitter_email, ~r/^[^\s<>]+@[^\s<>]+\.[^\s<>]+$/)
end
end
# Strip whitespace and upcase callsigns
defp sanitize_callsign(changeset, field) do
case get_change(changeset, field) do
nil -> changeset
val -> put_change(changeset, field, val |> String.trim() |> String.upcase())
end
end
# Upcase grid squares (Maidenhead uses uppercase letters + digits)
defp sanitize_grid(changeset, field) do
case get_change(changeset, field) do
nil -> changeset
val -> put_change(changeset, field, val |> String.trim() |> String.upcase())
end
end
# Callsigns: letters, digits, and / only (e.g. W5XD, KG5CCI/P, VE3/W5XD)
defp validate_callsign(changeset, field) do
validate_format(changeset, field, ~r/^[A-Z0-9\/]+$/, message: "must contain only letters, digits, and /")
end
defp validate_grid_format(changeset, field) do
validate_change(changeset, field, fn _, value ->
if Maidenhead.valid?(value) do
[]
else
[{field, "is not a valid Maidenhead grid square"}]
end
end)
end
end