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.
This commit is contained in:
parent
4ce7b21b3b
commit
fc245367e3
23 changed files with 611 additions and 94 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
102
lib/microwaveprop/radio/band_resolver.ex
Normal file
102
lib/microwaveprop/radio/band_resolver.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -69,9 +69,12 @@ defmodule MicrowavepropWeb.Layouts do
|
|||
Oban
|
||||
</.link>
|
||||
<%= if @current_scope && @current_scope.user do %>
|
||||
<span class="btn btn-ghost btn-sm pointer-events-none opacity-70">
|
||||
<.link
|
||||
navigate={~p"/u/#{@current_scope.user.callsign}"}
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{@current_scope.user.callsign}
|
||||
</span>
|
||||
</.link>
|
||||
<.link href={~p"/users/settings"} class="btn btn-ghost btn-sm">Settings</.link>
|
||||
<.link href={~p"/users/log-out"} method="delete" class="btn btn-ghost btn-sm">
|
||||
Log out
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
</:col>
|
||||
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
|
||||
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
|
||||
<:col :let={{_id, beacon}} label="Lat">{format_coord(beacon.lat)}</:col>
|
||||
<:col :let={{_id, beacon}} label="Lon">{format_coord(beacon.lon)}</:col>
|
||||
<:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>
|
||||
<:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft}</:col>
|
||||
<:col :let={{_id, beacon}} label="Keying">{Beacon.keying_label(beacon.keying)}</:col>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<th>Band</th>
|
||||
<th>Mode</th>
|
||||
<th>Distance (km)</th>
|
||||
<th>Submitted</th>
|
||||
<th>Enriched</th>
|
||||
<th></th>
|
||||
<th>Timestamp (UTC)</th>
|
||||
|
|
@ -162,7 +157,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
|||
<tbody>
|
||||
<%= for {primary, reciprocals} <- @grouped_contacts do %>
|
||||
<tr class="h-1">
|
||||
<td colspan="13"></td>
|
||||
<td colspan="12"></td>
|
||||
</tr>
|
||||
<tr
|
||||
class={[
|
||||
|
|
@ -187,7 +182,6 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
|||
<td>{primary.band}</td>
|
||||
<td>{primary.mode}</td>
|
||||
<td>{primary.distance_km}</td>
|
||||
<td>{submitted_cell(primary)}</td>
|
||||
<td>{enrichment_badge(primary)}</td>
|
||||
<td class="text-center">{if primary.flagged_invalid, do: "🚩"}</td>
|
||||
<td>{Calendar.strftime(primary.qso_timestamp, "%Y-%m-%d %H:%M")}</td>
|
||||
|
|
@ -206,7 +200,6 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
|||
<td>{recip.band}</td>
|
||||
<td>{recip.mode}</td>
|
||||
<td>{recip.distance_km}</td>
|
||||
<td>{submitted_cell(recip)}</td>
|
||||
<td>{enrichment_badge(recip)}</td>
|
||||
<td class="text-center">{if recip.flagged_invalid, do: "🚩"}</td>
|
||||
<td>{Calendar.strftime(recip.qso_timestamp, "%Y-%m-%d %H:%M")}</td>
|
||||
|
|
|
|||
|
|
@ -269,7 +269,11 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
<%!-- Auth --%>
|
||||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||||
<%= if @current_scope && @current_scope.user do %>
|
||||
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
|
||||
<li>
|
||||
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
||||
{@current_scope.user.callsign}
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/settings"}>Settings</.link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -651,7 +651,11 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
<%!-- Auth --%>
|
||||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||||
<%= if @current_scope && @current_scope.user do %>
|
||||
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
|
||||
<li>
|
||||
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
||||
{@current_scope.user.callsign}
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/settings"}>Settings</.link>
|
||||
</li>
|
||||
|
|
|
|||
170
lib/microwaveprop_web/live/user_profile_live.ex
Normal file
170
lib/microwaveprop_web/live/user_profile_live.ex
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
defmodule MicrowavepropWeb.UserProfileLive do
|
||||
@moduledoc false
|
||||
use MicrowavepropWeb, :live_view
|
||||
|
||||
alias Microwaveprop.Accounts
|
||||
alias Microwaveprop.Beacons
|
||||
alias Microwaveprop.Beacons.Beacon
|
||||
alias Microwaveprop.Radio
|
||||
|
||||
@impl true
|
||||
def mount(%{"callsign" => raw_callsign}, _session, socket) do
|
||||
case Accounts.get_user_by_callsign(raw_callsign) do
|
||||
nil ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "No profile for #{raw_callsign}.")
|
||||
|> push_navigate(to: ~p"/")}
|
||||
|
||||
user ->
|
||||
contacts = Radio.list_contacts_for_user(user)
|
||||
beacons = Beacons.list_beacons_for_user(user)
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
page_title: user.callsign,
|
||||
profile: user,
|
||||
contacts: contacts,
|
||||
beacons: beacons
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp initial(%{name: name}) when is_binary(name) and name != "" do
|
||||
name |> String.trim() |> String.first() |> String.upcase()
|
||||
end
|
||||
|
||||
defp initial(%{callsign: callsign}) when is_binary(callsign), do: callsign |> String.first() |> String.upcase()
|
||||
|
||||
defp initial(_), do: "?"
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
|
||||
<div class="card bg-base-200 shadow-sm mb-6">
|
||||
<div class="card-body flex-row items-center gap-4 py-5">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-16">
|
||||
<span class="text-2xl font-mono">{initial(@profile)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="card-title font-mono text-2xl">{@profile.callsign}</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
<span :if={@profile.name}>{@profile.name} ·</span>
|
||||
Member since {Calendar.strftime(@profile.inserted_at, "%B %Y")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats bg-base-200 shadow-sm mb-6 w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<.icon name="hero-radio" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Contacts submitted</div>
|
||||
<div class="stat-value text-primary">{length(@contacts)}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<.icon name="hero-signal" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Beacons submitted</div>
|
||||
<div class="stat-value text-secondary">{length(@beacons)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-sm mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Contacts</h2>
|
||||
<%= if @contacts == [] do %>
|
||||
<div class="alert">
|
||||
<.icon name="hero-information-circle" class="w-5 h-5" />
|
||||
<span>No contacts submitted yet.</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Stations</th>
|
||||
<th>Band</th>
|
||||
<th>Mode</th>
|
||||
<th>Distance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={contact <- @contacts} class="hover">
|
||||
<td class="whitespace-nowrap">
|
||||
{Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
|
||||
</td>
|
||||
<td>
|
||||
<.link navigate={~p"/contacts/#{contact.id}"} class="link link-hover font-mono">
|
||||
{contact.station1} ↔ {contact.station2}
|
||||
</.link>
|
||||
</td>
|
||||
<td>{contact.band && "#{contact.band} MHz"}</td>
|
||||
<td>{contact.mode}</td>
|
||||
<td>{contact.distance_km && "#{contact.distance_km} km"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Beacons</h2>
|
||||
<%= if @beacons == [] do %>
|
||||
<div class="alert">
|
||||
<.icon name="hero-information-circle" class="w-5 h-5" />
|
||||
<span>No beacons submitted yet.</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Callsign</th>
|
||||
<th>Frequency</th>
|
||||
<th>Grid</th>
|
||||
<th>Keying</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={beacon <- @beacons} class="hover">
|
||||
<td>
|
||||
<.link navigate={~p"/beacons/#{beacon.id}"} class="link link-hover font-mono">
|
||||
{beacon.callsign}
|
||||
</.link>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">
|
||||
{Beacon.format_freq(beacon.frequency_mhz)} MHz
|
||||
</td>
|
||||
<td>{beacon.grid}</td>
|
||||
<td>{Beacon.keying_label(beacon.keying)}</td>
|
||||
<td>
|
||||
<div class={[
|
||||
"badge badge-sm",
|
||||
if(beacon.approved, do: "badge-success", else: "badge-warning")
|
||||
]}>
|
||||
{if beacon.approved, do: "Approved", else: "Pending"}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -446,7 +446,11 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
<%!-- Auth --%>
|
||||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||||
<%= if @current_scope && @current_scope.user do %>
|
||||
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
|
||||
<li>
|
||||
<.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
|
||||
{@current_scope.user.callsign}
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/settings"}>Settings</.link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ defmodule MicrowavepropWeb.Router do
|
|||
live "/beacons", BeaconLive.Index, :index
|
||||
live "/beacons/new", BeaconLive.Form, :new
|
||||
live "/beacons/:id", BeaconLive.Show, :show
|
||||
|
||||
# Public per-user profile (contributions: contacts + beacons submitted)
|
||||
live "/u/:callsign", UserProfileLive
|
||||
end
|
||||
|
||||
# Redirect old /qsos routes
|
||||
|
|
|
|||
|
|
@ -18,6 +18,27 @@ defmodule Microwaveprop.AccountsTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "get_user_by_callsign/1" do
|
||||
test "returns the user for an exact callsign match" do
|
||||
%{id: id} = user = user_fixture(%{callsign: "W5ISP"})
|
||||
assert %User{id: ^id} = Accounts.get_user_by_callsign(user.callsign)
|
||||
end
|
||||
|
||||
test "is case-insensitive so /u/w5isp and /u/W5ISP both resolve" do
|
||||
%{id: id} = user_fixture(%{callsign: "W5ISP"})
|
||||
assert %User{id: ^id} = Accounts.get_user_by_callsign("w5isp")
|
||||
end
|
||||
|
||||
test "returns nil when no user matches" do
|
||||
refute Accounts.get_user_by_callsign("NO1SUCH")
|
||||
end
|
||||
|
||||
test "returns nil for nil or empty callsign" do
|
||||
refute Accounts.get_user_by_callsign(nil)
|
||||
refute Accounts.get_user_by_callsign("")
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_user_by_email_and_password/2" do
|
||||
test "does not return the user if the email does not exist" do
|
||||
refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
|
||||
|
|
|
|||
|
|
@ -132,6 +132,27 @@ defmodule Microwaveprop.BeaconsTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "list_beacons_for_user/1" do
|
||||
test "returns both approved and pending beacons owned by the user" do
|
||||
user = user_fixture()
|
||||
other = user_fixture()
|
||||
|
||||
{:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs())
|
||||
{:ok, approved} = Beacons.approve_beacon(approved)
|
||||
|
||||
pending = beacon_fixture(user, callsign: "W5PND")
|
||||
_theirs = beacon_fixture(other, callsign: "W5THR")
|
||||
|
||||
ids = user |> Beacons.list_beacons_for_user() |> Enum.map(& &1.id) |> Enum.sort()
|
||||
assert ids == Enum.sort([approved.id, pending.id])
|
||||
end
|
||||
|
||||
test "returns an empty list when the user owns nothing" do
|
||||
user = user_fixture()
|
||||
assert Beacons.list_beacons_for_user(user) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "approve_beacon/1" do
|
||||
test "marks the beacon as approved and broadcasts an update" do
|
||||
user = user_fixture()
|
||||
|
|
|
|||
|
|
@ -59,6 +59,26 @@ defmodule Microwaveprop.Radio.AdifImportTest do
|
|||
assert row.attrs["band"] == "1296"
|
||||
end
|
||||
|
||||
test "accepts 33cm ADIF band name and maps it to 902 MHz" do
|
||||
adif = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<BAND:4>33cm<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert [row] = preview.valid
|
||||
assert row.attrs["band"] == "902"
|
||||
end
|
||||
|
||||
test "maps a 902 MHz FREQ value onto the 902 band" do
|
||||
adif = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:7>903.100<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert [row] = preview.valid
|
||||
assert row.attrs["band"] == "902"
|
||||
end
|
||||
|
||||
test "maps ADIF frequency to correct band" do
|
||||
for {freq, expected_band} <- [
|
||||
{"1296.200", "1296"},
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
|||
end
|
||||
|
||||
test "reports invalid band" do
|
||||
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,99999,CW,2026-03-28T18:00:00Z"
|
||||
csv = @valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z"
|
||||
|
||||
assert {:ok, %{imported: [], errors: [{2, errors}]}} = CsvImport.import(csv, @submitter)
|
||||
assert errors != []
|
||||
|
|
@ -194,7 +194,7 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
|||
[
|
||||
@valid_header,
|
||||
@valid_row,
|
||||
"W5XD,K5TR,EM12,EM00,99999,CW,2026-03-28T18:00:00Z",
|
||||
"W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z",
|
||||
"N5AC,W5LUA,EM13,EM20,24000,SSB,2026-03-28T19:00:00Z"
|
||||
],
|
||||
"\n"
|
||||
|
|
@ -470,7 +470,7 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
|||
[
|
||||
@valid_header,
|
||||
"W5XD,K5TR,ZZZZ,EM00,10000,CW,2026-03-28T18:00:00Z",
|
||||
"W5XD,K5TR,EM12,EM00,99999,CW,2026-03-28T18:00:00Z"
|
||||
"W5XD,K5TR,EM12,EM00,notaband,CW,2026-03-28T18:00:00Z"
|
||||
],
|
||||
"\n"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
defmodule Microwaveprop.RadioTest do
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
import Microwaveprop.AccountsFixtures
|
||||
|
||||
alias Microwaveprop.Radio
|
||||
alias Microwaveprop.Radio.Contact
|
||||
|
||||
|
|
@ -452,4 +454,48 @@ defmodule Microwaveprop.RadioTest do
|
|||
assert errors_on(changeset).grid1
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_contacts_for_user/1" do
|
||||
test "returns only contacts whose user_id matches the user" do
|
||||
user = user_fixture()
|
||||
other = user_fixture()
|
||||
|
||||
owned =
|
||||
create_contact(%{station1: "W5OWN", user_id: user.id})
|
||||
|
||||
_their =
|
||||
create_contact(%{station1: "W5THR", user_id: other.id})
|
||||
|
||||
_anon = create_contact(%{station1: "W5ANO"})
|
||||
|
||||
assert [found] = Radio.list_contacts_for_user(user)
|
||||
assert found.id == owned.id
|
||||
end
|
||||
|
||||
test "orders newest first" do
|
||||
user = user_fixture()
|
||||
|
||||
_old =
|
||||
create_contact(%{
|
||||
qso_timestamp: ~U[2020-01-01 00:00:00Z],
|
||||
user_id: user.id,
|
||||
station1: "W5OLD"
|
||||
})
|
||||
|
||||
new =
|
||||
create_contact(%{
|
||||
qso_timestamp: ~U[2026-06-15 12:00:00Z],
|
||||
user_id: user.id,
|
||||
station1: "W5NEW"
|
||||
})
|
||||
|
||||
assert [first, _] = Radio.list_contacts_for_user(user)
|
||||
assert first.id == new.id
|
||||
end
|
||||
|
||||
test "returns an empty list when the user has submitted nothing" do
|
||||
user = user_fixture()
|
||||
assert Radio.list_contacts_for_user(user) == []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
defmodule MicrowavepropWeb.ContactLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: true
|
||||
|
||||
import Microwaveprop.AccountsFixtures
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Microwaveprop.Radio.Contact
|
||||
|
|
@ -116,31 +115,6 @@ defmodule MicrowavepropWeb.ContactLiveTest do
|
|||
{:ok, _lv, html2} = live(conn, ~p"/contacts?page=2")
|
||||
assert html2 =~ "Page 2 of 2"
|
||||
end
|
||||
|
||||
test "Submitted column shows the user's callsign when linked to an account", %{conn: conn} do
|
||||
user = user_fixture(%{callsign: "W5UNIQ"})
|
||||
create_contact(%{station1: "K5ONE", user_submitted: true, user_id: user.id})
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/contacts?search=K5ONE")
|
||||
assert html =~ "W5UNIQ"
|
||||
end
|
||||
|
||||
test "Submitted column shows 'Yes' when user_submitted but no user linked", %{conn: conn} do
|
||||
create_contact(%{station1: "K5ANON", user_submitted: true, user_id: nil})
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/contacts?search=K5ANON")
|
||||
# Locate the row containing our station and assert "Yes" appears in it
|
||||
[row] = Regex.run(~r/<tr[^>]*>(?:(?!<\/tr>).)*K5ANON(?:(?!<\/tr>).)*<\/tr>/s, html) || [""]
|
||||
assert row =~ ">Yes<"
|
||||
end
|
||||
|
||||
test "Submitted column shows dash for scraped contacts", %{conn: conn} do
|
||||
create_contact(%{station1: "K5SCRAPE", user_submitted: false})
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/contacts?search=K5SCRAPE")
|
||||
[row] = Regex.run(~r/<tr[^>]*>(?:(?!<\/tr>).)*K5SCRAPE(?:(?!<\/tr>).)*<\/tr>/s, html) || [""]
|
||||
refute row =~ ">Yes<"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Show" do
|
||||
|
|
|
|||
95
test/microwaveprop_web/live/user_profile_live_test.exs
Normal file
95
test/microwaveprop_web/live/user_profile_live_test.exs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
defmodule MicrowavepropWeb.UserProfileLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: true
|
||||
|
||||
import Microwaveprop.AccountsFixtures
|
||||
import Microwaveprop.BeaconsFixtures
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Microwaveprop.Beacons
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Repo
|
||||
|
||||
defp contact_for(user, attrs) do
|
||||
defaults = %{
|
||||
station1: "W5XD",
|
||||
station2: "K5TR",
|
||||
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||
mode: "CW",
|
||||
band: Decimal.new("10000"),
|
||||
grid1: "EM12",
|
||||
grid2: "EM00",
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
||||
distance_km: Decimal.new("295"),
|
||||
user_id: user.id
|
||||
}
|
||||
|
||||
{:ok, contact} =
|
||||
%Contact{}
|
||||
|> Contact.changeset(Map.merge(defaults, attrs))
|
||||
|> Repo.insert()
|
||||
|
||||
contact
|
||||
end
|
||||
|
||||
describe "mount" do
|
||||
test "renders a user's contributions", %{conn: conn} do
|
||||
user = user_fixture(%{callsign: "W5ISP"})
|
||||
_contact = contact_for(user, %{station1: "W5OWN"})
|
||||
_beacon = beacon_fixture(user, callsign: "W5ISP")
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
|
||||
|
||||
assert html =~ "W5ISP"
|
||||
assert html =~ "Contacts submitted"
|
||||
assert html =~ "Beacons submitted"
|
||||
assert html =~ "W5OWN"
|
||||
end
|
||||
|
||||
test "callsign lookup is case-insensitive", %{conn: conn} do
|
||||
_user = user_fixture(%{callsign: "W5ISP"})
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/u/w5isp")
|
||||
|
||||
assert html =~ "W5ISP"
|
||||
end
|
||||
|
||||
test "does not show contacts belonging to another user", %{conn: conn} do
|
||||
user = user_fixture(%{callsign: "W5ISP"})
|
||||
other = user_fixture(%{callsign: "W5OTH"})
|
||||
_owned = contact_for(user, %{station1: "W5MINE"})
|
||||
_theirs = contact_for(other, %{station1: "W5NOTMINE"})
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
|
||||
|
||||
assert html =~ "W5MINE"
|
||||
refute html =~ "W5NOTMINE"
|
||||
end
|
||||
|
||||
test "shows empty-state messages when the user has no submissions", %{conn: conn} do
|
||||
user = user_fixture(%{callsign: "W5EMP"})
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
|
||||
|
||||
assert html =~ "No contacts submitted yet"
|
||||
assert html =~ "No beacons submitted yet"
|
||||
end
|
||||
|
||||
test "redirects to the home page for an unknown callsign", %{conn: conn} do
|
||||
assert {:error, {:live_redirect, %{to: "/"}}} = live(conn, ~p"/u/NO1SUCH")
|
||||
end
|
||||
|
||||
test "lists both approved and pending beacons so owners see their drafts", %{conn: conn} do
|
||||
user = user_fixture(%{callsign: "W5ISP"})
|
||||
{:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs())
|
||||
{:ok, _approved} = Beacons.approve_beacon(approved)
|
||||
_pending = beacon_fixture(user, callsign: "W5PND")
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
|
||||
|
||||
assert html =~ "W5PND"
|
||||
assert html =~ "Pending"
|
||||
assert html =~ "Approved"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue