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

133 lines
3.7 KiB
Elixir

defmodule Microwaveprop.Beacons do
@moduledoc """
The Beacons context. Anyone can submit a beacon — authenticated or not —
but submissions are held as unapproved until an admin approves them.
Only approved beacons appear in the public list.
"""
import Ecto.Query, warn: false
alias Microwaveprop.Accounts.User
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Repo
@topic "beacons"
@doc """
Subscribes to beacon change notifications. Messages:
* `{:created, %Beacon{}}`
* `{:updated, %Beacon{}}`
* `{:deleted, %Beacon{}}`
"""
@spec subscribe_beacons() :: :ok | {:error, term()}
def subscribe_beacons do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, @topic)
end
defp broadcast(message) do
Phoenix.PubSub.broadcast(Microwaveprop.PubSub, @topic, message)
end
@doc "Returns approved beacons ordered by most recently added."
@spec list_beacons() :: [Beacon.t()]
def list_beacons do
Repo.all(
from b in Beacon,
where: b.approved == true,
order_by: [desc: b.inserted_at]
)
end
@doc "Returns unapproved beacons awaiting admin review."
@spec list_pending_beacons() :: [Beacon.t()]
def list_pending_beacons do
Repo.all(
from b in Beacon,
where: b.approved == false,
order_by: [asc: b.inserted_at]
)
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)
@doc """
Creates a beacon. When a user is provided they are recorded as the
creator; anonymous submissions pass `nil` and leave `user_id` unset.
"""
@spec create_beacon(User.t() | nil, map()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
def create_beacon(user, attrs)
def create_beacon(%User{} = user, attrs) do
%Beacon{user_id: user.id}
|> Beacon.changeset(attrs)
|> Repo.insert()
|> broadcast_if_ok(:created)
end
def create_beacon(nil, attrs) do
%Beacon{}
|> Beacon.changeset(attrs)
|> Repo.insert()
|> broadcast_if_ok(:created)
end
@doc "Updates a beacon."
@spec update_beacon(Beacon.t(), map()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
def update_beacon(%Beacon{} = beacon, attrs) do
beacon
|> Beacon.changeset(attrs)
|> Repo.update()
|> broadcast_if_ok(:updated)
end
@doc "Marks a beacon as approved, making it visible in the public list."
@spec approve_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
def approve_beacon(%Beacon{} = beacon) do
beacon
|> Ecto.Changeset.change(approved: true)
|> Repo.update()
|> broadcast_if_ok(:updated)
end
@doc "Deletes a beacon."
@spec delete_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
def delete_beacon(%Beacon{} = beacon) do
case Repo.delete(beacon) do
{:ok, beacon} ->
broadcast({:deleted, beacon})
{:ok, beacon}
other ->
other
end
end
@doc "Returns an `%Ecto.Changeset{}` for tracking beacon changes."
@spec change_beacon(Beacon.t(), map()) :: Ecto.Changeset.t()
def change_beacon(%Beacon{} = beacon, attrs \\ %{}) do
Beacon.changeset(beacon, attrs)
end
defp broadcast_if_ok({:ok, beacon} = result, type) do
broadcast({type, beacon})
result
end
defp broadcast_if_ok(other, _type), do: other
end