simplify: DRY up shared changesets, context helpers, LiveView helpers, and structural extraction
- Create MaidenheadChangesetHelpers: consolidate grid validation, callsign normalization, lat/lon validation, grid/latlon derivation across 6 schemas - Create ContextHelpers: shared fetch_owned with admin bypass, safe_enqueue for Oban workers, UUID casting to replace CastError rescues - Extend LiveHelpers: add current_user/1 (removes 7 duplicate definitions), subscribe/2 (replaces 13 inline PubSub sites), assign_url_params/2 - Extract Propagation.ScoreStore (528 lines): separate file I/O and cache management from scoring logic, 13 defdelegate passthroughs - Split SubmitLive (942->475 lines): extract CSV/ADIF upload rendering into 3 function component modules (csv_upload, adif_upload, preview) - Update 16 LiveViews to use shared helpers
This commit is contained in:
parent
377ef70f5c
commit
b4b8d4ec47
37 changed files with 1477 additions and 1425 deletions
|
|
@ -9,6 +9,7 @@ defmodule Microwaveprop.Accounts do
|
||||||
alias Microwaveprop.Accounts.UserApiToken
|
alias Microwaveprop.Accounts.UserApiToken
|
||||||
alias Microwaveprop.Accounts.UserNotifier
|
alias Microwaveprop.Accounts.UserNotifier
|
||||||
alias Microwaveprop.Accounts.UserToken
|
alias Microwaveprop.Accounts.UserToken
|
||||||
|
alias Microwaveprop.ContextHelpers
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
## Database getters
|
## Database getters
|
||||||
alias Microwaveprop.Workers.UserHomeQthLookupWorker
|
alias Microwaveprop.Workers.UserHomeQthLookupWorker
|
||||||
|
|
@ -152,11 +153,7 @@ defmodule Microwaveprop.Accounts do
|
||||||
defp maybe_enqueue_home_qth_lookup({:ok, %User{id: id, callsign: call} = user}) when is_binary(call) do
|
defp maybe_enqueue_home_qth_lookup({:ok, %User{id: id, callsign: call} = user}) when is_binary(call) do
|
||||||
_ =
|
_ =
|
||||||
if home_qth_lookup_enabled?() do
|
if home_qth_lookup_enabled?() do
|
||||||
worker = UserHomeQthLookupWorker
|
ContextHelpers.safe_enqueue(UserHomeQthLookupWorker, %{user_id: id})
|
||||||
|
|
||||||
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
|
|
||||||
_ = %{user_id: id} |> worker.new() |> Oban.insert()
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, user}
|
{:ok, user}
|
||||||
|
|
@ -516,22 +513,24 @@ defmodule Microwaveprop.Accounts do
|
||||||
@spec revoke_api_token(User.t(), Ecto.UUID.t()) ::
|
@spec revoke_api_token(User.t(), Ecto.UUID.t()) ::
|
||||||
{:ok, UserApiToken.t()} | {:error, :not_found}
|
{:ok, UserApiToken.t()} | {:error, :not_found}
|
||||||
def revoke_api_token(%User{id: user_id}, token_id) do
|
def revoke_api_token(%User{id: user_id}, token_id) do
|
||||||
case Repo.get_by(UserApiToken, id: token_id, user_id: user_id) do
|
case Ecto.UUID.cast(token_id) do
|
||||||
nil ->
|
{:ok, uuid} ->
|
||||||
|
case Repo.get_by(UserApiToken, id: uuid, user_id: user_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%UserApiToken{revoked_at: nil} = token ->
|
||||||
|
token
|
||||||
|
|> Ecto.Changeset.change(revoked_at: DateTime.utc_now(:second))
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
|
%UserApiToken{} = token ->
|
||||||
|
{:ok, token}
|
||||||
|
end
|
||||||
|
|
||||||
|
:error ->
|
||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
%UserApiToken{revoked_at: nil} = token ->
|
|
||||||
token
|
|
||||||
|> Ecto.Changeset.change(revoked_at: DateTime.utc_now(:second))
|
|
||||||
|> Repo.update()
|
|
||||||
|
|
||||||
%UserApiToken{} = token ->
|
|
||||||
{:ok, token}
|
|
||||||
end
|
end
|
||||||
rescue
|
|
||||||
# Malformed UUID in the URL — treat as a clean 404 instead of
|
|
||||||
# bubbling a 500 out of `Repo.get_by`'s UUID cast.
|
|
||||||
Ecto.Query.CastError -> {:error, :not_found}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
## Token helper
|
## Token helper
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ defmodule Microwaveprop.Accounts.User do
|
||||||
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
|
||||||
|
|
||||||
@admin_email "graham@mcintire.me"
|
@admin_email "graham@mcintire.me"
|
||||||
|
|
||||||
|
|
@ -227,10 +227,10 @@ defmodule Microwaveprop.Accounts.User do
|
||||||
user
|
user
|
||||||
|> cast(attrs, [:home_grid, :home_lat, :home_lon, :home_elevation_m])
|
|> cast(attrs, [:home_grid, :home_lat, :home_lon, :home_elevation_m])
|
||||||
|> normalize_home_grid()
|
|> normalize_home_grid()
|
||||||
|> validate_home_grid_format()
|
|> MCH.validate_grid(:home_grid, blank_error: false)
|
||||||
|> derive_home_latlon_from_grid()
|
|> MCH.derive_latlon(:home_grid, :home_lat, :home_lon)
|
||||||
|> derive_home_grid_from_latlon()
|
|> MCH.derive_grid(:home_grid, :home_lat, :home_lon, 10)
|
||||||
|> validate_home_latlon_range()
|
|> MCH.validate_latlon(:home_lat, :home_lon)
|
||||||
|> require_home_qth_pair()
|
|> require_home_qth_pair()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -243,86 +243,10 @@ defmodule Microwaveprop.Accounts.User do
|
||||||
put_change(changeset, :home_grid, nil)
|
put_change(changeset, :home_grid, nil)
|
||||||
|
|
||||||
grid when is_binary(grid) ->
|
grid when is_binary(grid) ->
|
||||||
put_change(changeset, :home_grid, normalize_grid(grid))
|
put_change(changeset, :home_grid, MCH.normalize_grid(grid))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_grid(grid) do
|
|
||||||
trimmed = String.trim(grid)
|
|
||||||
|
|
||||||
case String.length(trimmed) do
|
|
||||||
4 -> String.upcase(trimmed)
|
|
||||||
6 -> String.upcase(String.slice(trimmed, 0, 4)) <> String.downcase(String.slice(trimmed, 4, 2))
|
|
||||||
_ -> trimmed
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp validate_home_grid_format(changeset) do
|
|
||||||
case get_field(changeset, :home_grid) do
|
|
||||||
nil ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
grid ->
|
|
||||||
if Maidenhead.valid?(grid),
|
|
||||||
do: changeset,
|
|
||||||
else: add_error(changeset, :home_grid, "must be a 4 or 6 character Maidenhead locator")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp derive_home_latlon_from_grid(changeset) do
|
|
||||||
cond do
|
|
||||||
not changeset.valid? ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
get_change(changeset, :home_lat) || get_change(changeset, :home_lon) ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
grid = get_change(changeset, :home_grid) ->
|
|
||||||
case Maidenhead.to_latlon(grid) do
|
|
||||||
{:ok, {lat, lon}} ->
|
|
||||||
changeset
|
|
||||||
|> put_change(:home_lat, lat)
|
|
||||||
|> put_change(:home_lon, lon)
|
|
||||||
|
|
||||||
:error ->
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
|
|
||||||
true ->
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# If lat/lon were provided without a grid, encode a 10-char Maidenhead
|
|
||||||
# grid from the coordinates so the UI always has a human-readable label
|
|
||||||
# to render and downstream callers can rely on `home_grid` being set
|
|
||||||
# whenever the QTH is set. Skipped on invalid changesets (lat/lon range
|
|
||||||
# check has not run yet) and when both fields aren't numeric.
|
|
||||||
defp derive_home_grid_from_latlon(changeset) do
|
|
||||||
lat = get_field(changeset, :home_lat)
|
|
||||||
lon = get_field(changeset, :home_lon)
|
|
||||||
|
|
||||||
cond do
|
|
||||||
not changeset.valid? ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
not (is_number(lat) and is_number(lon)) ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
get_field(changeset, :home_grid) not in [nil, ""] ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
true ->
|
|
||||||
put_change(changeset, :home_grid, Maidenhead.from_latlon(lat * 1.0, lon * 1.0, 10))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp validate_home_latlon_range(changeset) do
|
|
||||||
changeset
|
|
||||||
|> validate_number(:home_lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|
|
||||||
|> validate_number(:home_lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp require_home_qth_pair(changeset) do
|
defp require_home_qth_pair(changeset) do
|
||||||
if home_qth_satisfied?(changeset),
|
if home_qth_satisfied?(changeset),
|
||||||
do: changeset,
|
do: changeset,
|
||||||
|
|
|
||||||
|
|
@ -48,32 +48,25 @@ defmodule Microwaveprop.BeaconMeasurements do
|
||||||
defp lookup_beacon(attrs) do
|
defp lookup_beacon(attrs) do
|
||||||
attrs = Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
|
attrs = Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
|
||||||
|
|
||||||
case Map.get(attrs, "beacon_id") do
|
with id when is_binary(id) and byte_size(id) > 0 <- Map.get(attrs, "beacon_id"),
|
||||||
id when is_binary(id) and byte_size(id) > 0 ->
|
{:ok, uuid} <- Ecto.UUID.cast(id) do
|
||||||
# Only let monitors report for approved on-the-air beacons. The
|
# Only let monitors report for approved on-the-air beacons. The
|
||||||
# client drops 404s without retry, so an admin un-approving a
|
# client drops 404s without retry, so an admin un-approving a
|
||||||
# beacon cleanly stops the monitor pointing at it.
|
# beacon cleanly stops the monitor pointing at it.
|
||||||
query =
|
query =
|
||||||
from b in Microwaveprop.Beacons.Beacon,
|
from b in Microwaveprop.Beacons.Beacon,
|
||||||
where: b.id == ^id and b.approved == true and b.on_the_air == true,
|
where: b.id == ^uuid and b.approved == true and b.on_the_air == true,
|
||||||
select: b.id
|
select: b.id
|
||||||
|
|
||||||
case safe_one(query) do
|
case Repo.one(query) do
|
||||||
nil -> :not_found
|
nil -> :not_found
|
||||||
uuid -> {:ok, uuid}
|
uuid -> {:ok, uuid}
|
||||||
end
|
end
|
||||||
|
else
|
||||||
_ ->
|
_ -> :not_found
|
||||||
:not_found
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp safe_one(query) do
|
|
||||||
Repo.one(query)
|
|
||||||
rescue
|
|
||||||
Ecto.Query.CastError -> nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# The wire payload uses string keys (Phoenix JSON params). Make sure
|
# The wire payload uses string keys (Phoenix JSON params). Make sure
|
||||||
# we don't drop a value because some intermediate handed us atom keys.
|
# we don't drop a value because some intermediate handed us atom keys.
|
||||||
defp normalize_attrs(attrs) do
|
defp normalize_attrs(attrs) do
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ defmodule Microwaveprop.BeaconMonitors do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias Ecto.Query.CastError
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.BeaconMonitors.BeaconMonitor
|
alias Microwaveprop.BeaconMonitors.BeaconMonitor
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
|
@ -143,16 +142,20 @@ defmodule Microwaveprop.BeaconMonitors do
|
||||||
@spec delete_monitor(User.t(), Ecto.UUID.t()) ::
|
@spec delete_monitor(User.t(), Ecto.UUID.t()) ::
|
||||||
{:ok, BeaconMonitor.t()} | {:error, :not_found}
|
{:ok, BeaconMonitor.t()} | {:error, :not_found}
|
||||||
def delete_monitor(%User{id: user_id}, monitor_id) do
|
def delete_monitor(%User{id: user_id}, monitor_id) do
|
||||||
query =
|
case Ecto.UUID.cast(monitor_id) do
|
||||||
from m in BeaconMonitor,
|
{:ok, uuid} ->
|
||||||
where: m.id == ^monitor_id and m.user_id == ^user_id
|
query =
|
||||||
|
from m in BeaconMonitor,
|
||||||
|
where: m.id == ^uuid and m.user_id == ^user_id
|
||||||
|
|
||||||
case Repo.one(query) do
|
case Repo.one(query) do
|
||||||
nil -> {:error, :not_found}
|
nil -> {:error, :not_found}
|
||||||
monitor -> Repo.delete(monitor)
|
monitor -> Repo.delete(monitor)
|
||||||
|
end
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:error, :not_found}
|
||||||
end
|
end
|
||||||
rescue
|
|
||||||
CastError -> {:error, :not_found}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -160,12 +163,16 @@ defmodule Microwaveprop.BeaconMonitors do
|
||||||
"""
|
"""
|
||||||
@spec delete_monitor!(Ecto.UUID.t()) :: {:ok, BeaconMonitor.t()} | {:error, :not_found}
|
@spec delete_monitor!(Ecto.UUID.t()) :: {:ok, BeaconMonitor.t()} | {:error, :not_found}
|
||||||
def delete_monitor!(monitor_id) do
|
def delete_monitor!(monitor_id) do
|
||||||
case Repo.get(BeaconMonitor, monitor_id) do
|
case Ecto.UUID.cast(monitor_id) do
|
||||||
nil -> {:error, :not_found}
|
{:ok, uuid} ->
|
||||||
monitor -> Repo.delete(monitor)
|
case Repo.get(BeaconMonitor, uuid) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
monitor -> Repo.delete(monitor)
|
||||||
|
end
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:error, :not_found}
|
||||||
end
|
end
|
||||||
rescue
|
|
||||||
CastError -> {:error, :not_found}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── Auth / heartbeat ─────────────────────────────────────────────
|
# ── Auth / heartbeat ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.Beacons.Beacon
|
alias Microwaveprop.Beacons.Beacon
|
||||||
|
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
|
||||||
|
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
|
|
@ -90,8 +91,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|
||||||
|> validate_length(:antenna_type, max: 100)
|
|> validate_length(:antenna_type, max: 100)
|
||||||
|> validate_length(:callsign, max: 20)
|
|> validate_length(:callsign, max: 20)
|
||||||
|> validate_number(:antenna_gain_dbi, greater_than_or_equal_to: -20, less_than_or_equal_to: 60)
|
|> validate_number(:antenna_gain_dbi, greater_than_or_equal_to: -20, less_than_or_equal_to: 60)
|
||||||
|> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
|
|> MCH.validate_latlon(:lat, :lon)
|
||||||
|> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|
|
||||||
|> foreign_key_constraint(:user_id)
|
|> foreign_key_constraint(:user_id)
|
||||||
|> foreign_key_constraint(:assigned_by_id)
|
|> foreign_key_constraint(:assigned_by_id)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,12 @@ defmodule Microwaveprop.Beacons do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_beacon(id) do
|
defp fetch_beacon(id) do
|
||||||
Beacon |> Repo.get(id) |> Repo.preload(:user)
|
with {:ok, uuid} <- Ecto.UUID.cast(id),
|
||||||
rescue
|
%Beacon{} = beacon <- Repo.get(Beacon, uuid) do
|
||||||
Ecto.Query.CastError -> nil
|
Repo.preload(beacon, :user)
|
||||||
|
else
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp can_view?(%Beacon{approved: true}, _viewer), do: true
|
defp can_view?(%Beacon{approved: true}, _viewer), do: true
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ defmodule Microwaveprop.Beacons.Beacon do
|
||||||
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
|
||||||
|
|
||||||
@keying_entries [
|
@keying_entries [
|
||||||
{"on_off", "On/Off"},
|
{"on_off", "On/Off"},
|
||||||
|
|
@ -180,12 +180,12 @@ defmodule Microwaveprop.Beacons.Beacon do
|
||||||
:beamwidth_deg,
|
:beamwidth_deg,
|
||||||
:notes
|
:notes
|
||||||
])
|
])
|
||||||
|> update_change(:callsign, fn cs -> cs && String.upcase(String.trim(cs)) end)
|
|> MCH.normalize_callsign(:callsign)
|
||||||
|> update_change(:lat, &round_coord/1)
|
|> update_change(:lat, &round_coord/1)
|
||||||
|> update_change(:lon, &round_coord/1)
|
|> update_change(:lon, &round_coord/1)
|
||||||
|> update_change(:height_ft, &round_int/1)
|
|> update_change(:height_ft, &round_int/1)
|
||||||
|> maybe_fill_latlon()
|
|> MCH.derive_latlon(:grid, :lat, :lon)
|
||||||
|> maybe_fill_grid()
|
|> MCH.derive_grid(:grid, :lat, :lon)
|
||||||
|> normalize_bearing_change()
|
|> normalize_bearing_change()
|
||||||
|> validate_required(@required_fields)
|
|> validate_required(@required_fields)
|
||||||
|> validate_inclusion(:keying, @keyings)
|
|> validate_inclusion(:keying, @keyings)
|
||||||
|
|
@ -194,10 +194,9 @@ defmodule Microwaveprop.Beacons.Beacon do
|
||||||
|> validate_number(:frequency_mhz, greater_than: 0)
|
|> validate_number(:frequency_mhz, greater_than: 0)
|
||||||
|> validate_number(:power_mw, greater_than_or_equal_to: 0)
|
|> validate_number(:power_mw, greater_than_or_equal_to: 0)
|
||||||
|> validate_number(:height_ft, greater_than_or_equal_to: 0)
|
|> validate_number(:height_ft, greater_than_or_equal_to: 0)
|
||||||
|> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
|
|> MCH.validate_latlon(:lat, :lon)
|
||||||
|> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|
|
||||||
|> validate_length(:callsign, min: 3, max: 10)
|
|> validate_length(:callsign, min: 3, max: 10)
|
||||||
|> validate_grid_format()
|
|> MCH.validate_grid(:grid)
|
||||||
|> foreign_key_constraint(:user_id)
|
|> foreign_key_constraint(:user_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -210,40 +209,6 @@ defmodule Microwaveprop.Beacons.Beacon do
|
||||||
defp round_int(v) when is_integer(v), do: v
|
defp round_int(v) when is_integer(v), do: v
|
||||||
defp round_int(v), do: v
|
defp round_int(v), do: v
|
||||||
|
|
||||||
# If grid is blank but lat/lon are valid, derive it.
|
|
||||||
defp maybe_fill_grid(changeset) do
|
|
||||||
grid = get_field(changeset, :grid)
|
|
||||||
lat = get_field(changeset, :lat)
|
|
||||||
lon = get_field(changeset, :lon)
|
|
||||||
|
|
||||||
if grid in [nil, ""] and is_number(lat) and is_number(lon) do
|
|
||||||
put_change(changeset, :grid, Maidenhead.from_latlon(lat, lon, 6))
|
|
||||||
else
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# If lat/lon are blank but grid is a valid Maidenhead, derive them.
|
|
||||||
defp maybe_fill_latlon(changeset) do
|
|
||||||
grid = get_field(changeset, :grid)
|
|
||||||
lat = get_field(changeset, :lat)
|
|
||||||
lon = get_field(changeset, :lon)
|
|
||||||
|
|
||||||
if is_binary(grid) and grid != "" and (lat in [nil, ""] or lon in [nil, ""]) do
|
|
||||||
case Maidenhead.to_latlon(grid) do
|
|
||||||
{:ok, {new_lat, new_lon}} ->
|
|
||||||
changeset
|
|
||||||
|> put_change(:lat, Float.round(new_lat * 1.0, 6))
|
|
||||||
|> put_change(:lon, Float.round(new_lon * 1.0, 6))
|
|
||||||
|
|
||||||
:error ->
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
else
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Normalize bearing: trim, treat nil/blank/"omni" (any case) as "omni".
|
# Normalize bearing: trim, treat nil/blank/"omni" (any case) as "omni".
|
||||||
# Anything else is left for validate_bearing to check as a number.
|
# Anything else is left for validate_bearing to check as a number.
|
||||||
defp normalize_bearing_change(changeset) do
|
defp normalize_bearing_change(changeset) do
|
||||||
|
|
@ -289,32 +254,4 @@ defmodule Microwaveprop.Beacons.Beacon do
|
||||||
changeset
|
changeset
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp validate_grid_format(changeset) do
|
|
||||||
case get_field(changeset, :grid) do
|
|
||||||
nil ->
|
|
||||||
add_error(changeset, :grid, "can't be blank")
|
|
||||||
|
|
||||||
grid ->
|
|
||||||
if Maidenhead.valid?(grid) do
|
|
||||||
update_change(changeset, :grid, &normalize_grid/1)
|
|
||||||
else
|
|
||||||
add_error(changeset, :grid, "is not a valid Maidenhead grid")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Field uppercase, square digits, subsquare lowercase (standard form)
|
|
||||||
defp normalize_grid(nil), do: nil
|
|
||||||
|
|
||||||
defp normalize_grid(grid) do
|
|
||||||
grid = String.trim(grid)
|
|
||||||
len = String.length(grid)
|
|
||||||
|
|
||||||
if len >= 6 do
|
|
||||||
grid |> String.slice(0, 4) |> String.upcase() |> Kernel.<>(String.downcase(String.slice(grid, 4, len - 4)))
|
|
||||||
else
|
|
||||||
String.upcase(grid)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
67
lib/microwaveprop/context_helpers.ex
Normal file
67
lib/microwaveprop/context_helpers.ex
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
defmodule Microwaveprop.ContextHelpers do
|
||||||
|
@moduledoc """
|
||||||
|
Shared helpers for context modules: owner-scoped fetches, safe Oban
|
||||||
|
enqueuing, and UUID casting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches a record by id, scoped to the given user. Admins bypass
|
||||||
|
ownership — they can fetch any record. Regular users can only fetch
|
||||||
|
records where `user_id` matches.
|
||||||
|
|
||||||
|
Validates that `id` is a UUID before hitting the database; non-UUID
|
||||||
|
strings return `{:error, :not_found}` instead of raising CastError.
|
||||||
|
|
||||||
|
Returns `{:ok, record}` or `{:error, :not_found}`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
fetch_owned(MySchema, "some-uuid", admin_user)
|
||||||
|
fetch_owned(MySchema, "some-uuid", regular_user)
|
||||||
|
"""
|
||||||
|
@spec fetch_owned(module(), Ecto.UUID.t(), User.t()) :: {:ok, Ecto.Schema.t()} | {:error, :not_found}
|
||||||
|
def fetch_owned(schema, id, user) do
|
||||||
|
with {:ok, uuid} <- cast_uuid(id) do
|
||||||
|
do_fetch_owned(schema, uuid, user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_fetch_owned(schema, uuid, %User{is_admin: true}) do
|
||||||
|
case Repo.get(schema, uuid) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
record -> {:ok, record}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_fetch_owned(schema, uuid, %User{id: user_id}) do
|
||||||
|
case Repo.get(schema, uuid) do
|
||||||
|
%{user_id: ^user_id} = record -> {:ok, record}
|
||||||
|
_ -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Safe Oban enqueue — only enqueues if the worker module is loaded and
|
||||||
|
exports `new/1`. Returns whatever `Oban.insert/1` returns on success,
|
||||||
|
or `{:error, :worker_not_available}` if the worker is not available.
|
||||||
|
"""
|
||||||
|
@spec safe_enqueue(module(), map()) :: {:ok, Oban.Job.t()} | {:error, :worker_not_available | Ecto.Changeset.t()}
|
||||||
|
def safe_enqueue(worker, args \\ %{}) do
|
||||||
|
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
|
||||||
|
args |> worker.new() |> Oban.insert()
|
||||||
|
else
|
||||||
|
{:error, :worker_not_available}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Safely casts a string to a UUID. Returns `{:ok, uuid}` or `:error`.
|
||||||
|
Use this at API/controller boundaries so contexts never receive bad UUIDs.
|
||||||
|
"""
|
||||||
|
@spec cast_uuid(binary()) :: {:ok, Ecto.UUID.t()} | :error
|
||||||
|
def cast_uuid(id) when is_binary(id), do: Ecto.UUID.cast(id)
|
||||||
|
def cast_uuid(_), do: :error
|
||||||
|
end
|
||||||
|
|
@ -4,14 +4,13 @@ defmodule Microwaveprop.Propagation do
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
alias Microwaveprop.Propagation.Grid
|
|
||||||
alias Microwaveprop.Propagation.ProfilesFile
|
|
||||||
alias Microwaveprop.Propagation.RunTiming
|
alias Microwaveprop.Propagation.RunTiming
|
||||||
alias Microwaveprop.Propagation.ScoreCache
|
|
||||||
alias Microwaveprop.Propagation.Scorer
|
alias Microwaveprop.Propagation.Scorer
|
||||||
alias Microwaveprop.Propagation.ScoresFile
|
alias Microwaveprop.Propagation.ScoreStore
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Weather.ScalarFile
|
|
||||||
|
# ── ML Model Lifecycle ──────────────────────────────────────────────
|
||||||
|
|
||||||
alias Microwaveprop.Weather.SoundingParams
|
alias Microwaveprop.Weather.SoundingParams
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
@ -41,6 +40,8 @@ defmodule Microwaveprop.Propagation do
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Logger.info("PropagationML: ML dependencies not available")
|
Logger.info("PropagationML: ML dependencies not available")
|
||||||
|
|
||||||
|
# ── Scoring ─────────────────────────────────────────────────────────
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -174,494 +175,30 @@ defmodule Microwaveprop.Propagation do
|
||||||
max(hrrr_rate, nexrad_rate)
|
max(hrrr_rate, nexrad_rate)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
defdelegate replace_scores(scores, valid_time), to: ScoreStore
|
||||||
Replace every propagation score for `valid_time` with `scores`.
|
defdelegate prune_old_scores(), to: ScoreStore
|
||||||
|
defdelegate retain_scores_window(run_time), to: ScoreStore
|
||||||
|
defdelegate available_valid_times(band_mhz), to: ScoreStore
|
||||||
|
# ── Delegates to ScoreStore (file I/O + cache) ──────────────────────
|
||||||
|
defdelegate hot_cache_window(), to: ScoreStore
|
||||||
|
defdelegate scores_at(band_mhz, valid_time, bounds \\ nil), to: ScoreStore
|
||||||
|
defdelegate scores_at_fresh(band_mhz, valid_time, bounds \\ nil), to: ScoreStore
|
||||||
|
defdelegate warm_cache_and_broadcast(band_mhz, valid_time), to: ScoreStore
|
||||||
|
defdelegate latest_scores(band_mhz, bounds \\ nil), to: ScoreStore
|
||||||
|
defdelegate point_forecast(band_mhz, lat, lon), to: ScoreStore
|
||||||
|
defdelegate point_detail(band_mhz, lat, lon, valid_time \\ nil), to: ScoreStore
|
||||||
|
defdelegate latest_valid_time(), to: ScoreStore
|
||||||
|
|
||||||
Used by `PropagationGridWorker` on the hot path. Scores are written
|
# ── Scoring helpers called from ScoreStore ──────────────────────────
|
||||||
as binary files on disk via `ScoresFile.write!/3`, one file per
|
defdelegate latest_valid_time(band_mhz), to: ScoreStore
|
||||||
band.
|
|
||||||
|
|
||||||
Consumes `scores` in a single streaming pass that folds each score
|
|
||||||
straight into a per-band accumulator. Previously this function ran
|
|
||||||
`Enum.to_list/1` followed by `Enum.group_by/2`, which held two full
|
|
||||||
copies of the ~460k-entry grid (list + grouped list) in memory at
|
|
||||||
once — the hot path's largest transient spike after native-duct
|
|
||||||
merge. The single-pass reduce keeps only one copy and buys back
|
|
||||||
~100 MB of headroom per forecast-hour step.
|
|
||||||
"""
|
|
||||||
@spec replace_scores(Enumerable.t(), DateTime.t()) :: {:ok, non_neg_integer()} | {:error, term()}
|
|
||||||
def replace_scores(scores, %DateTime{} = valid_time) do
|
|
||||||
do_replace_scores(scores, valid_time)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp do_replace_scores(scores, valid_time) do
|
|
||||||
# Pure grouping phase runs outside the telemetry span — typically
|
|
||||||
# <10ms on small result sets, and the span's two dispatches
|
|
||||||
# (~100µs each) would otherwise dominate. The span now wraps only
|
|
||||||
# the per-band writes, which is where the actual DB cost lives.
|
|
||||||
{per_band, total} =
|
|
||||||
Enum.reduce(scores, {%{}, 0}, fn score, {acc, count} ->
|
|
||||||
{Map.update(acc, score.band_mhz, [score], &[score | &1]), count + 1}
|
|
||||||
end)
|
|
||||||
|
|
||||||
Microwaveprop.Instrument.span(
|
|
||||||
[:db, :replace_scores],
|
|
||||||
%{valid_time: valid_time},
|
|
||||||
fn ->
|
|
||||||
Enum.each(per_band, fn {band_mhz, band_scores} ->
|
|
||||||
try do
|
|
||||||
ScoresFile.write!(band_mhz, valid_time, band_scores)
|
|
||||||
rescue
|
|
||||||
e ->
|
|
||||||
Logger.warning("Propagation: ScoresFile write failed for band=#{band_mhz} vt=#{valid_time}: #{inspect(e)}")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:ok, total}
|
|
||||||
end
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Remove score files with valid_times older than 3 hours. Called on
|
Rebuild the factor breakdown for a clicked grid cell by rescoring
|
||||||
a cron by `Microwaveprop.Workers.PropagationPruneWorker`.
|
the persisted HRRR profile. Made public so ScoreStore can call it
|
||||||
|
after reading the profile from disk.
|
||||||
The cutoff sits one hour beyond HRRR's ~2h publish lag: the hourly
|
|
||||||
seeder picks `run_time = now - 2h`, so the f00 analysis file is
|
|
||||||
written at valid_time = now - 2h. A 2h cutoff deletes it within
|
|
||||||
minutes; a 3h cutoff keeps it alive until the next hourly run
|
|
||||||
supersedes it.
|
|
||||||
"""
|
"""
|
||||||
@spec prune_old_scores() :: :ok
|
@spec factors_from_profile(non_neg_integer(), DateTime.t(), map(), float(), float()) :: map()
|
||||||
def prune_old_scores do
|
def factors_from_profile(band_mhz, valid_time, profile, lat, lon) do
|
||||||
cutoff = DateTime.shift(DateTime.utc_now(), hour: -3)
|
|
||||||
file_deleted = ScoresFile.prune_older_than(cutoff)
|
|
||||||
profiles_deleted = ProfilesFile.prune_older_than(cutoff)
|
|
||||||
scalar_deleted = ScalarFile.prune_older_than(cutoff)
|
|
||||||
|
|
||||||
total = file_deleted + profiles_deleted + scalar_deleted
|
|
||||||
|
|
||||||
if total > 0 do
|
|
||||||
Logger.info(
|
|
||||||
"PropagationScores: pruned #{file_deleted} old score files + " <>
|
|
||||||
"#{profiles_deleted} profile files + #{scalar_deleted} scalar dirs " <>
|
|
||||||
"(before #{cutoff})"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Sweep orphaned .tmp.* files left by crashed atomic-write processes
|
|
||||||
tmp_deleted =
|
|
||||||
Enum.reduce([ScoresFile.base_dir(), ProfilesFile.base_dir(), ScalarFile.base_dir()], 0, &sweep_tmp_dir/2)
|
|
||||||
|
|
||||||
if tmp_deleted > 0 do
|
|
||||||
Logger.info("PropagationScores: swept #{tmp_deleted} orphaned .tmp files")
|
|
||||||
end
|
|
||||||
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp sweep_tmp_dir(dir, acc) do
|
|
||||||
case File.ls(dir) do
|
|
||||||
{:ok, entries} ->
|
|
||||||
Enum.reduce(entries, acc, &sweep_tmp_entry(dir, &1, &2))
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
acc
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp sweep_tmp_entry(parent, entry, acc) do
|
|
||||||
full = Path.join(parent, entry)
|
|
||||||
|
|
||||||
case File.ls(full) do
|
|
||||||
{:ok, _} ->
|
|
||||||
sweep_tmp_dir(full, acc)
|
|
||||||
|
|
||||||
{:error, _} ->
|
|
||||||
if String.contains?(entry, ".tmp.") do
|
|
||||||
_ = File.rm_rf(full)
|
|
||||||
acc + 1
|
|
||||||
else
|
|
||||||
acc
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Retains score files through GEFS's 168-hour horizon and profile/scalar
|
|
||||||
files through HRRR's 48-hour horizon, deleting files older than
|
|
||||||
`run_time`. Called
|
|
||||||
by `NotifyListener` after chain completion to keep `/data/scores`
|
|
||||||
within bounds.
|
|
||||||
|
|
||||||
Mirrors `ScoreCache.prune_outside_window` for the on-disk tier.
|
|
||||||
"""
|
|
||||||
@spec retain_scores_window(DateTime.t()) :: :ok
|
|
||||||
def retain_scores_window(%DateTime{} = run_time) do
|
|
||||||
scores_deleted = ScoresFile.retain_window(run_time, 168)
|
|
||||||
profiles_deleted = ProfilesFile.retain_window(run_time, 48)
|
|
||||||
scalars_deleted = ScalarFile.retain_window(run_time, 48)
|
|
||||||
total = scores_deleted + profiles_deleted + scalars_deleted
|
|
||||||
|
|
||||||
if total > 0 do
|
|
||||||
Logger.info(
|
|
||||||
"Propagation.retain_scores_window: deleted #{total} stale files (#{scores_deleted} scores, #{profiles_deleted} profiles, #{scalars_deleted} scalars)"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns distinct valid_times for a band, ordered ascending. Always
|
|
||||||
reads from the on-disk `ScoresFile` store — the `ScoreCache` only
|
|
||||||
holds whatever hours have been fetched or broadcast, which can be a
|
|
||||||
partial view of what's actually on disk, so using it as the source
|
|
||||||
of truth for the timeline makes new forecast hours invisible until
|
|
||||||
the cache happens to catch up. Filters out times more than 1 hour in
|
|
||||||
the past, but always includes the most recent valid_time so there's
|
|
||||||
always data to display.
|
|
||||||
"""
|
|
||||||
# HRRR forecast horizon: f00..f48 covers the next 48 hours from cycle
|
|
||||||
# time (f01-f18 hourly, f21-f48 3-hourly). Anything beyond that in the
|
|
||||||
# score store is a leftover from a stale cycle and clutters the
|
|
||||||
# timeline without adding information.
|
|
||||||
@hrrr_forecast_horizon_hours 48
|
|
||||||
|
|
||||||
@spec available_valid_times(non_neg_integer()) :: [DateTime.t()]
|
|
||||||
def available_valid_times(band_mhz) do
|
|
||||||
{past_cutoff, future_cutoff} = hot_cache_window()
|
|
||||||
|
|
||||||
case ScoresFile.list_valid_times(band_mhz) do
|
|
||||||
[] -> []
|
|
||||||
times -> filter_or_latest(times, past_cutoff, future_cutoff)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
The active forecast window for the `/map` UI: one hour in the past
|
|
||||||
through HRRR's 48-hour forecast horizon. Used by `NotifyListener` to
|
|
||||||
bound ETS growth — long-horizon GEFS `.prop` files on disk must not
|
|
||||||
balloon `propagation_score_cache` past the memory the UI actually
|
|
||||||
reads.
|
|
||||||
"""
|
|
||||||
@spec hot_cache_window() :: {DateTime.t(), DateTime.t()}
|
|
||||||
def hot_cache_window do
|
|
||||||
now = DateTime.utc_now()
|
|
||||||
past = DateTime.shift(now, hour: -1)
|
|
||||||
future = DateTime.shift(now, hour: @hrrr_forecast_horizon_hours)
|
|
||||||
{past, future}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp filter_or_latest(times, past_cutoff, future_cutoff) do
|
|
||||||
fresh =
|
|
||||||
Enum.filter(times, fn t ->
|
|
||||||
DateTime.compare(t, past_cutoff) != :lt and DateTime.compare(t, future_cutoff) != :gt
|
|
||||||
end)
|
|
||||||
|
|
||||||
if fresh == [] do
|
|
||||||
[Enum.max(times, DateTime)]
|
|
||||||
else
|
|
||||||
fresh
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Get scores for a band at a specific valid_time, optionally within a bounding box.
|
|
||||||
If valid_time is nil, uses the earliest available (current analysis hour).
|
|
||||||
Excludes factors for performance.
|
|
||||||
"""
|
|
||||||
@spec scores_at(non_neg_integer(), DateTime.t() | nil, %{optional(String.t()) => float()} | nil) ::
|
|
||||||
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
|
|
||||||
def scores_at(band_mhz, valid_time, bounds \\ nil) do
|
|
||||||
time = valid_time || earliest_valid_time(band_mhz)
|
|
||||||
|
|
||||||
case time do
|
|
||||||
nil -> []
|
|
||||||
_ -> scores_at_fetch(band_mhz, time, bounds)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Cache-hit path is the map's most frequent LiveView call (~every
|
|
||||||
# pan + click). A wrapping Instrument.span fires 2 telemetry handler
|
|
||||||
# dispatches that dominate the ~10µs ETS lookup — skip the span on
|
|
||||||
# hits and rely on the cheap hit/miss counter for the cache-ratio
|
|
||||||
# panel. The miss path still wraps the disk read where duration is
|
|
||||||
# the meaningful signal.
|
|
||||||
defp scores_at_fetch(band_mhz, time, bounds) do
|
|
||||||
case ScoreCache.fetch_bounds(band_mhz, time, bounds) do
|
|
||||||
{:ok, scores} ->
|
|
||||||
:telemetry.execute([:microwaveprop, :propagation, :scores_at, :cache], %{}, %{hit: true})
|
|
||||||
Enum.map(scores, &Map.put(&1, :valid_time, time))
|
|
||||||
|
|
||||||
:miss ->
|
|
||||||
:telemetry.execute([:microwaveprop, :propagation, :scores_at, :cache], %{}, %{hit: false})
|
|
||||||
|
|
||||||
Microwaveprop.Instrument.span([:propagation, :scores_at], %{band_mhz: band_mhz}, fn ->
|
|
||||||
read_from_disk_and_cache(band_mhz, time, bounds)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Variant of `scores_at/3` that always reads from the `.prop` file on
|
|
||||||
disk and overwrites the cache entry, rather than returning whatever
|
|
||||||
the cache happens to hold. Use from update paths (the map's
|
|
||||||
`propagation_updated` handler) where the underlying file has just
|
|
||||||
been rewritten but the cache may still contain the previous chain's
|
|
||||||
scores because of the race between `propagation:cache` fan-out and
|
|
||||||
`propagation:updated` delivery.
|
|
||||||
"""
|
|
||||||
@spec scores_at_fresh(non_neg_integer(), DateTime.t(), %{optional(String.t()) => float()} | nil) ::
|
|
||||||
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
|
|
||||||
def scores_at_fresh(band_mhz, %DateTime{} = valid_time, bounds \\ nil) do
|
|
||||||
read_from_disk_and_cache(band_mhz, valid_time, bounds)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp read_from_disk_and_cache(band_mhz, time, bounds) do
|
|
||||||
full = ScoresFile.read_bounds(band_mhz, time)
|
|
||||||
ScoreCache.put(band_mhz, time, full)
|
|
||||||
|
|
||||||
full
|
|
||||||
|> filter_bounds(bounds)
|
|
||||||
|> Enum.map(&Map.put(&1, :valid_time, time))
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Load the full North America score set for `{band_mhz, valid_time}` from
|
|
||||||
the on-disk binary files (HRRR `.prop` + HRDPS `.hrdps.prop`, merged) and
|
|
||||||
broadcast it to every `ScoreCache` in the cluster. Called from
|
|
||||||
`PropagationGridWorker` after each forecast hour so all pods have a warm
|
|
||||||
cache by the time clients begin requesting the new hour.
|
|
||||||
|
|
||||||
Returns `{:error, :enoent}` only when neither file exists. A single
|
|
||||||
missing file (HRDPS pre-cycle, HRRR briefly absent) is OK — the cache
|
|
||||||
warms with whichever side is available.
|
|
||||||
"""
|
|
||||||
@spec warm_cache_and_broadcast(non_neg_integer(), DateTime.t()) ::
|
|
||||||
:ok | {:error, :enoent | :invalid_format}
|
|
||||||
def warm_cache_and_broadcast(band_mhz, valid_time) do
|
|
||||||
hrrr = read_score_points(&ScoresFile.read/2, band_mhz, valid_time)
|
|
||||||
hrdps = read_score_points(&ScoresFile.read_hrdps/2, band_mhz, valid_time)
|
|
||||||
|
|
||||||
# Merge priority: HRRR > HRDPS > GEFS. GEFS provides extended-horizon
|
|
||||||
# coverage beyond HRRR's 48h window. Within the f24-f48 overlap, cells
|
|
||||||
# already present in HRRR/HRDPS are skipped so the coarser GEFS scores
|
|
||||||
# don't override the higher-resolution ones.
|
|
||||||
merged = (hrrr || []) ++ (hrdps || [])
|
|
||||||
gefs = read_score_points(&ScoresFile.read_gefs/2, band_mhz, valid_time) || []
|
|
||||||
scores = ScoresFile.merge_preferred(merged, gefs)
|
|
||||||
|
|
||||||
case {hrrr, hrdps, scores} do
|
|
||||||
{nil, nil, []} ->
|
|
||||||
{:error, :enoent}
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
ScoreCache.broadcast_put(band_mhz, valid_time, scores)
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp read_score_points(reader, band_mhz, valid_time) do
|
|
||||||
case reader.(band_mhz, valid_time) do
|
|
||||||
{:ok, payload} -> ScoresFile.extract_points(payload, nil)
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp filter_bounds(scores, nil), do: scores
|
|
||||||
|
|
||||||
defp filter_bounds(scores, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
|
|
||||||
Enum.filter(scores, fn %{lat: lat, lon: lon} ->
|
|
||||||
lat >= s and lat <= n and lon >= w and lon <= e
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc "Get the latest scores for a band (alias for scores_at with earliest valid_time)."
|
|
||||||
@spec latest_scores(non_neg_integer(), %{optional(String.t()) => float()} | nil) ::
|
|
||||||
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
|
|
||||||
def latest_scores(band_mhz, bounds \\ nil) do
|
|
||||||
scores_at(band_mhz, nil, bounds)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp earliest_valid_time(band_mhz) do
|
|
||||||
case ScoresFile.list_valid_times(band_mhz) do
|
|
||||||
[earliest | _] -> earliest
|
|
||||||
[] -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc "Get scores across all forecast hours for a single grid point (for sparkline)."
|
|
||||||
@spec point_forecast(non_neg_integer(), float(), float()) ::
|
|
||||||
[%{valid_time: DateTime.t(), score: non_neg_integer()}]
|
|
||||||
def point_forecast(band_mhz, lat, lon) do
|
|
||||||
Microwaveprop.Instrument.span([:propagation, :point_forecast], %{band_mhz: band_mhz}, fn ->
|
|
||||||
{snapped_lat, snapped_lon} = snap_to_grid(lat, lon)
|
|
||||||
now = DateTime.utc_now()
|
|
||||||
|
|
||||||
# Use the on-disk .prop file list as the authoritative timeline so
|
|
||||||
# the chart never falls behind the main-map timeline (which also
|
|
||||||
# reads the disk). The cache is still consulted per-hour for a
|
|
||||||
# fast score lookup; a miss falls through to the file.
|
|
||||||
# Fan the per-hour disk lookups across 4 tasks. Each ScoresFile
|
|
||||||
# read_point is an NFS stat + pread of ~100 bytes (keyed byte at
|
|
||||||
# row*cols+col), so the ceiling is NFS RTT × number of hours —
|
|
||||||
# sequential ran ~4–5× the wall time of the slowest read.
|
|
||||||
band_mhz
|
|
||||||
|> ScoresFile.list_valid_times()
|
|
||||||
|> forecast_window(now)
|
|
||||||
|> Task.async_stream(
|
|
||||||
&point_forecast_entry(band_mhz, &1, snapped_lat, snapped_lon),
|
|
||||||
max_concurrency: 4,
|
|
||||||
ordered: true,
|
|
||||||
timeout: 5_000
|
|
||||||
)
|
|
||||||
|> Enum.flat_map(fn
|
|
||||||
{:ok, nil} ->
|
|
||||||
[]
|
|
||||||
|
|
||||||
{:ok, entry} ->
|
|
||||||
[entry]
|
|
||||||
|
|
||||||
{:exit, reason} ->
|
|
||||||
Logger.error(
|
|
||||||
"Propagation.point_forecast async lookup failed: band_mhz=#{band_mhz} lat=#{snapped_lat} lon=#{snapped_lon} reason=#{inspect(reason)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
[]
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp point_forecast_entry(band_mhz, valid_time, lat, lon) do
|
|
||||||
case ScoreCache.fetch_point(band_mhz, valid_time, lat, lon) do
|
|
||||||
{:ok, score} ->
|
|
||||||
%{valid_time: valid_time, score: score}
|
|
||||||
|
|
||||||
:miss ->
|
|
||||||
case ScoresFile.read_point(band_mhz, valid_time, lat, lon) do
|
|
||||||
nil -> nil
|
|
||||||
score -> %{valid_time: valid_time, score: score}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Select the set of valid_times the forecast chart should render.
|
|
||||||
# Mirrors `available_valid_times`: keep everything from one hour
|
|
||||||
# before now onward so the most recent analysis hour (typically
|
|
||||||
# ~30–60 min behind wall clock due to HRRR publishing lag) sits at
|
|
||||||
# the left edge of the chart as "now". When every hour on disk is
|
|
||||||
# older than that cutoff, fall back to just the newest entry so the
|
|
||||||
# chart can still render a single data point.
|
|
||||||
defp forecast_window([], _now), do: []
|
|
||||||
|
|
||||||
defp forecast_window(times, now) do
|
|
||||||
past_cutoff = DateTime.shift(now, hour: -1)
|
|
||||||
future_cutoff = DateTime.shift(now, hour: @hrrr_forecast_horizon_hours)
|
|
||||||
filter_or_latest(times, past_cutoff, future_cutoff)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp snap_to_grid(lat, lon) do
|
|
||||||
step = Grid.step()
|
|
||||||
{Float.round(Float.round(lat / step) * step, 3), Float.round(Float.round(lon / step) * step, 3)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Get the full score and factors for a specific grid point, snapped to
|
|
||||||
the nearest grid cell.
|
|
||||||
|
|
||||||
`:profile_source` describes where the factor breakdown came from:
|
|
||||||
|
|
||||||
* `:exact` — rescored from this `valid_time`'s own profile file.
|
|
||||||
* `{:fallback, fallback_valid_time}` — the requested hour's profile
|
|
||||||
was missing, so we rescored from the most recent analysis profile
|
|
||||||
within the lookback window. Treat as approximate.
|
|
||||||
* `:unavailable` — no profile available; `factors` is `%{}`.
|
|
||||||
"""
|
|
||||||
@spec point_detail(non_neg_integer(), float(), float(), DateTime.t() | nil) ::
|
|
||||||
%{
|
|
||||||
lat: float(),
|
|
||||||
lon: float(),
|
|
||||||
score: non_neg_integer(),
|
|
||||||
factors: map(),
|
|
||||||
profile_source: :exact | {:fallback, DateTime.t()} | :unavailable,
|
|
||||||
valid_time: DateTime.t()
|
|
||||||
}
|
|
||||||
| nil
|
|
||||||
def point_detail(band_mhz, lat, lon, valid_time \\ nil) do
|
|
||||||
Microwaveprop.Instrument.span([:propagation, :point_detail], %{band_mhz: band_mhz}, fn ->
|
|
||||||
do_point_detail(band_mhz, lat, lon, valid_time)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp do_point_detail(band_mhz, lat, lon, valid_time) do
|
|
||||||
{snapped_lat, snapped_lon} = snap_to_grid(lat, lon)
|
|
||||||
time = valid_time || latest_valid_time(band_mhz)
|
|
||||||
|
|
||||||
case time do
|
|
||||||
nil ->
|
|
||||||
nil
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
case ScoresFile.read_point(band_mhz, time, snapped_lat, snapped_lon) do
|
|
||||||
nil ->
|
|
||||||
nil
|
|
||||||
|
|
||||||
score ->
|
|
||||||
{factors, source} = factors_for(band_mhz, time, snapped_lat, snapped_lon)
|
|
||||||
|
|
||||||
%{
|
|
||||||
lat: snapped_lat,
|
|
||||||
lon: snapped_lon,
|
|
||||||
score: score,
|
|
||||||
factors: factors,
|
|
||||||
profile_source: source,
|
|
||||||
valid_time: time
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Rebuild the factor breakdown for a clicked grid cell by rescoring
|
|
||||||
# the persisted HRRR profile.
|
|
||||||
#
|
|
||||||
# The Rust pipeline (`prop_grid_rs`) writes a per-cell profile file
|
|
||||||
# for every chain step (f00..f18 since Phase 2 cutover), so the
|
|
||||||
# `:exact` branch covers a healthy production state. The fallback to
|
|
||||||
# a recent analysis profile remains as a safety net for missed
|
|
||||||
# cycles or partial chain runs — when it kicks in we tag the result
|
|
||||||
# `{:fallback, profile_valid_time}` so the UI can label the
|
|
||||||
# breakdown as approximated rather than silently misrepresent it.
|
|
||||||
@fallback_profile_lookback_hours 24
|
|
||||||
@spec factors_for(non_neg_integer(), DateTime.t(), float(), float()) ::
|
|
||||||
{map(), :exact | {:fallback, DateTime.t()} | :unavailable}
|
|
||||||
defp factors_for(band_mhz, valid_time, lat, lon) do
|
|
||||||
case ProfilesFile.read_point(valid_time, lat, lon) do
|
|
||||||
nil ->
|
|
||||||
factors_from_fallback_profile(band_mhz, valid_time, lat, lon)
|
|
||||||
|
|
||||||
profile ->
|
|
||||||
{factors_from_profile(band_mhz, valid_time, profile, lat, lon), :exact}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp factors_from_fallback_profile(band_mhz, valid_time, lat, lon) do
|
|
||||||
case latest_profile_time_within_lookback(valid_time) do
|
|
||||||
nil ->
|
|
||||||
{%{}, :unavailable}
|
|
||||||
|
|
||||||
fallback_time ->
|
|
||||||
case ProfilesFile.read_point(fallback_time, lat, lon) do
|
|
||||||
nil ->
|
|
||||||
{%{}, :unavailable}
|
|
||||||
|
|
||||||
profile ->
|
|
||||||
{factors_from_profile(band_mhz, fallback_time, profile, lat, lon), {:fallback, fallback_time}}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp factors_from_profile(band_mhz, valid_time, profile, lat, lon) do
|
|
||||||
profile
|
profile
|
||||||
|> Map.put(:kp_index, current_kp_index())
|
|> Map.put(:kp_index, current_kp_index())
|
||||||
|> score_grid_point(valid_time, lat, lon)
|
|> score_grid_point(valid_time, lat, lon)
|
||||||
|
|
@ -684,38 +221,10 @@ defmodule Microwaveprop.Propagation do
|
||||||
%{estimated_kp: kp} when is_number(kp) -> trunc(kp)
|
%{estimated_kp: kp} when is_number(kp) -> trunc(kp)
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Run timings ─────────────────────────────────────────────────────
|
||||||
end
|
end
|
||||||
|
|
||||||
defp latest_profile_time_within_lookback(%DateTime{} = valid_time) do
|
|
||||||
lookback_cutoff = DateTime.shift(valid_time, hour: -@fallback_profile_lookback_hours)
|
|
||||||
|
|
||||||
ProfilesFile.list_valid_times()
|
|
||||||
|> Enum.filter(fn t ->
|
|
||||||
DateTime.compare(t, valid_time) != :gt and DateTime.compare(t, lookback_cutoff) != :lt
|
|
||||||
end)
|
|
||||||
|> case do
|
|
||||||
[] -> nil
|
|
||||||
past -> Enum.max(past, DateTime)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc "Get the latest valid_time across all bands."
|
|
||||||
@spec latest_valid_time() :: DateTime.t() | nil
|
|
||||||
def latest_valid_time do
|
|
||||||
ScoresFile.latest_valid_time()
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc "Get the latest valid_time for a specific band."
|
|
||||||
@spec latest_valid_time(non_neg_integer()) :: DateTime.t() | nil
|
|
||||||
def latest_valid_time(band_mhz) do
|
|
||||||
case ScoresFile.list_valid_times(band_mhz) do
|
|
||||||
[] -> nil
|
|
||||||
times -> Enum.max(times, DateTime)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
## Run timings
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Record wall-clock duration for a single forecast-hour chain step.
|
Record wall-clock duration for a single forecast-hour chain step.
|
||||||
|
|
||||||
|
|
@ -746,6 +255,8 @@ defmodule Microwaveprop.Propagation do
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Derived factors ─────────────────────────────────────────────────
|
||||||
|
|
||||||
# Prefer the persisted scalar — `hrrr_profiles` already stored this at
|
# Prefer the persisted scalar — `hrrr_profiles` already stored this at
|
||||||
# ingestion time and AsosAdjustmentWorker loads 92k rows per tick without
|
# ingestion time and AsosAdjustmentWorker loads 92k rows per tick without
|
||||||
# the JSONB `profile` column to avoid a Jason.decode! storm on the DB pool.
|
# the JSONB `profile` column to avoid a Jason.decode! storm on the DB pool.
|
||||||
|
|
|
||||||
528
lib/microwaveprop/propagation/score_store.ex
Normal file
528
lib/microwaveprop/propagation/score_store.ex
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
defmodule Microwaveprop.Propagation.ScoreStore do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias Microwaveprop.Instrument
|
||||||
|
alias Microwaveprop.Propagation.Grid
|
||||||
|
alias Microwaveprop.Propagation.ProfilesFile
|
||||||
|
alias Microwaveprop.Propagation.ScoreCache
|
||||||
|
alias Microwaveprop.Propagation.ScoresFile
|
||||||
|
alias Microwaveprop.Weather.ScalarFile
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@hrrr_forecast_horizon_hours 48
|
||||||
|
@fallback_profile_lookback_hours 24
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Replace every propagation score for `valid_time` with `scores`.
|
||||||
|
|
||||||
|
Used by `PropagationGridWorker` on the hot path. Scores are written
|
||||||
|
as binary files on disk via `ScoresFile.write!/3`, one file per
|
||||||
|
band.
|
||||||
|
|
||||||
|
Consumes `scores` in a single streaming pass that folds each score
|
||||||
|
straight into a per-band accumulator. Previously this function ran
|
||||||
|
`Enum.to_list/1` followed by `Enum.group_by/2`, which held two full
|
||||||
|
copies of the ~460k-entry grid (list + grouped list) in memory at
|
||||||
|
once — the hot path's largest transient spike after native-duct
|
||||||
|
merge. The single-pass reduce keeps only one copy and buys back
|
||||||
|
~100 MB of headroom per forecast-hour step.
|
||||||
|
"""
|
||||||
|
@spec replace_scores(Enumerable.t(), DateTime.t()) :: {:ok, non_neg_integer()} | {:error, term()}
|
||||||
|
def replace_scores(scores, %DateTime{} = valid_time) do
|
||||||
|
do_replace_scores(scores, valid_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_replace_scores(scores, valid_time) do
|
||||||
|
# Pure grouping phase runs outside the telemetry span — typically
|
||||||
|
# <10ms on small result sets, and the span's two dispatches
|
||||||
|
# (~100µs each) would otherwise dominate. The span now wraps only
|
||||||
|
# the per-band writes, which is where the actual DB cost lives.
|
||||||
|
{per_band, total} =
|
||||||
|
Enum.reduce(scores, {%{}, 0}, fn score, {acc, count} ->
|
||||||
|
{Map.update(acc, score.band_mhz, [score], &[score | &1]), count + 1}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Instrument.span(
|
||||||
|
[:db, :replace_scores],
|
||||||
|
%{valid_time: valid_time},
|
||||||
|
fn ->
|
||||||
|
Enum.each(per_band, fn {band_mhz, band_scores} ->
|
||||||
|
try do
|
||||||
|
ScoresFile.write!(band_mhz, valid_time, band_scores)
|
||||||
|
rescue
|
||||||
|
e ->
|
||||||
|
Logger.warning("Propagation: ScoresFile write failed for band=#{band_mhz} vt=#{valid_time}: #{inspect(e)}")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, total}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Remove score files with valid_times older than 3 hours. Called on
|
||||||
|
a cron by `Microwaveprop.Workers.PropagationPruneWorker`.
|
||||||
|
|
||||||
|
The cutoff sits one hour beyond HRRR's ~2h publish lag: the hourly
|
||||||
|
seeder picks `run_time = now - 2h`, so the f00 analysis file is
|
||||||
|
written at valid_time = now - 2h. A 2h cutoff deletes it within
|
||||||
|
minutes; a 3h cutoff keeps it alive until the next hourly run
|
||||||
|
supersedes it.
|
||||||
|
"""
|
||||||
|
@spec prune_old_scores() :: :ok
|
||||||
|
def prune_old_scores do
|
||||||
|
cutoff = DateTime.shift(DateTime.utc_now(), hour: -3)
|
||||||
|
file_deleted = ScoresFile.prune_older_than(cutoff)
|
||||||
|
profiles_deleted = ProfilesFile.prune_older_than(cutoff)
|
||||||
|
scalar_deleted = ScalarFile.prune_older_than(cutoff)
|
||||||
|
|
||||||
|
total = file_deleted + profiles_deleted + scalar_deleted
|
||||||
|
|
||||||
|
if total > 0 do
|
||||||
|
Logger.info(
|
||||||
|
"PropagationScores: pruned #{file_deleted} old score files + " <>
|
||||||
|
"#{profiles_deleted} profile files + #{scalar_deleted} scalar dirs " <>
|
||||||
|
"(before #{cutoff})"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sweep orphaned .tmp.* files left by crashed atomic-write processes
|
||||||
|
tmp_deleted =
|
||||||
|
Enum.reduce([ScoresFile.base_dir(), ProfilesFile.base_dir(), ScalarFile.base_dir()], 0, &sweep_tmp_dir/2)
|
||||||
|
|
||||||
|
if tmp_deleted > 0 do
|
||||||
|
Logger.info("PropagationScores: swept #{tmp_deleted} orphaned .tmp files")
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sweep_tmp_dir(dir, acc) do
|
||||||
|
case File.ls(dir) do
|
||||||
|
{:ok, entries} ->
|
||||||
|
Enum.reduce(entries, acc, &sweep_tmp_entry(dir, &1, &2))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sweep_tmp_entry(parent, entry, acc) do
|
||||||
|
full = Path.join(parent, entry)
|
||||||
|
|
||||||
|
case File.ls(full) do
|
||||||
|
{:ok, _} ->
|
||||||
|
sweep_tmp_dir(full, acc)
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
if String.contains?(entry, ".tmp.") do
|
||||||
|
_ = File.rm_rf(full)
|
||||||
|
acc + 1
|
||||||
|
else
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Retains score files through GEFS's 168-hour horizon and profile/scalar
|
||||||
|
files through HRRR's 48-hour horizon, deleting files older than
|
||||||
|
`run_time`. Called
|
||||||
|
by `NotifyListener` after chain completion to keep `/data/scores`
|
||||||
|
within bounds.
|
||||||
|
|
||||||
|
Mirrors `ScoreCache.prune_outside_window` for the on-disk tier.
|
||||||
|
"""
|
||||||
|
@spec retain_scores_window(DateTime.t()) :: :ok
|
||||||
|
def retain_scores_window(%DateTime{} = run_time) do
|
||||||
|
scores_deleted = ScoresFile.retain_window(run_time, 168)
|
||||||
|
profiles_deleted = ProfilesFile.retain_window(run_time, 48)
|
||||||
|
scalars_deleted = ScalarFile.retain_window(run_time, 48)
|
||||||
|
total = scores_deleted + profiles_deleted + scalars_deleted
|
||||||
|
|
||||||
|
if total > 0 do
|
||||||
|
Logger.info(
|
||||||
|
"Propagation.retain_scores_window: deleted #{total} stale files (#{scores_deleted} scores, #{profiles_deleted} profiles, #{scalars_deleted} scalars)"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns distinct valid_times for a band, ordered ascending. Always
|
||||||
|
reads from the on-disk `ScoresFile` store — the `ScoreCache` only
|
||||||
|
holds whatever hours have been fetched or broadcast, which can be a
|
||||||
|
partial view of what's actually on disk, so using it as the source
|
||||||
|
of truth for the timeline makes new forecast hours invisible until
|
||||||
|
the cache happens to catch up. Filters out times more than 1 hour in
|
||||||
|
the past, but always includes the most recent valid_time so there's
|
||||||
|
always data to display.
|
||||||
|
"""
|
||||||
|
# HRRR forecast horizon: f00..f48 covers the next 48 hours from cycle
|
||||||
|
# time (f01-f18 hourly, f21-f48 3-hourly). Anything beyond that in the
|
||||||
|
# score store is a leftover from a stale cycle and clutters the
|
||||||
|
# timeline without adding information.
|
||||||
|
@spec available_valid_times(non_neg_integer()) :: [DateTime.t()]
|
||||||
|
def available_valid_times(band_mhz) do
|
||||||
|
{past_cutoff, future_cutoff} = hot_cache_window()
|
||||||
|
|
||||||
|
case ScoresFile.list_valid_times(band_mhz) do
|
||||||
|
[] -> []
|
||||||
|
times -> filter_or_latest(times, past_cutoff, future_cutoff)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The active forecast window for the `/map` UI: one hour in the past
|
||||||
|
through HRRR's 48-hour forecast horizon. Used by `NotifyListener` to
|
||||||
|
bound ETS growth — long-horizon GEFS `.prop` files on disk must not
|
||||||
|
balloon `propagation_score_cache` past the memory the UI actually
|
||||||
|
reads.
|
||||||
|
"""
|
||||||
|
@spec hot_cache_window() :: {DateTime.t(), DateTime.t()}
|
||||||
|
def hot_cache_window do
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
past = DateTime.shift(now, hour: -1)
|
||||||
|
future = DateTime.shift(now, hour: @hrrr_forecast_horizon_hours)
|
||||||
|
{past, future}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_or_latest(times, past_cutoff, future_cutoff) do
|
||||||
|
fresh =
|
||||||
|
Enum.filter(times, fn t ->
|
||||||
|
DateTime.compare(t, past_cutoff) != :lt and DateTime.compare(t, future_cutoff) != :gt
|
||||||
|
end)
|
||||||
|
|
||||||
|
if fresh == [] do
|
||||||
|
[Enum.max(times, DateTime)]
|
||||||
|
else
|
||||||
|
fresh
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get scores for a band at a specific valid_time, optionally within a bounding box.
|
||||||
|
If valid_time is nil, uses the earliest available (current analysis hour).
|
||||||
|
Excludes factors for performance.
|
||||||
|
"""
|
||||||
|
@spec scores_at(non_neg_integer(), DateTime.t() | nil, %{optional(String.t()) => float()} | nil) ::
|
||||||
|
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
|
||||||
|
def scores_at(band_mhz, valid_time, bounds \\ nil) do
|
||||||
|
time = valid_time || earliest_valid_time(band_mhz)
|
||||||
|
|
||||||
|
case time do
|
||||||
|
nil -> []
|
||||||
|
_ -> scores_at_fetch(band_mhz, time, bounds)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cache-hit path is the map's most frequent LiveView call (~every
|
||||||
|
# pan + click). A wrapping Instrument.span fires 2 telemetry handler
|
||||||
|
# dispatches that dominate the ~10µs ETS lookup — skip the span on
|
||||||
|
# hits and rely on the cheap hit/miss counter for the cache-ratio
|
||||||
|
# panel. The miss path still wraps the disk read where duration is
|
||||||
|
# the meaningful signal.
|
||||||
|
defp scores_at_fetch(band_mhz, time, bounds) do
|
||||||
|
case ScoreCache.fetch_bounds(band_mhz, time, bounds) do
|
||||||
|
{:ok, scores} ->
|
||||||
|
:telemetry.execute([:microwaveprop, :propagation, :scores_at, :cache], %{}, %{hit: true})
|
||||||
|
Enum.map(scores, &Map.put(&1, :valid_time, time))
|
||||||
|
|
||||||
|
:miss ->
|
||||||
|
:telemetry.execute([:microwaveprop, :propagation, :scores_at, :cache], %{}, %{hit: false})
|
||||||
|
|
||||||
|
Instrument.span([:propagation, :scores_at], %{band_mhz: band_mhz}, fn ->
|
||||||
|
read_from_disk_and_cache(band_mhz, time, bounds)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Variant of `scores_at/3` that always reads from the `.prop` file on
|
||||||
|
disk and overwrites the cache entry, rather than returning whatever
|
||||||
|
the cache happens to hold. Use from update paths (the map's
|
||||||
|
`propagation_updated` handler) where the underlying file has just
|
||||||
|
been rewritten but the cache may still contain the previous chain's
|
||||||
|
scores because of the race between `propagation:cache` fan-out and
|
||||||
|
`propagation:updated` delivery.
|
||||||
|
"""
|
||||||
|
@spec scores_at_fresh(non_neg_integer(), DateTime.t(), %{optional(String.t()) => float()} | nil) ::
|
||||||
|
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
|
||||||
|
def scores_at_fresh(band_mhz, %DateTime{} = valid_time, bounds \\ nil) do
|
||||||
|
read_from_disk_and_cache(band_mhz, valid_time, bounds)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_from_disk_and_cache(band_mhz, time, bounds) do
|
||||||
|
full = ScoresFile.read_bounds(band_mhz, time)
|
||||||
|
ScoreCache.put(band_mhz, time, full)
|
||||||
|
|
||||||
|
full
|
||||||
|
|> filter_bounds(bounds)
|
||||||
|
|> Enum.map(&Map.put(&1, :valid_time, time))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Load the full North America score set for `{band_mhz, valid_time}` from
|
||||||
|
the on-disk binary files (HRRR `.prop` + HRDPS `.hrdps.prop`, merged) and
|
||||||
|
broadcast it to every `ScoreCache` in the cluster. Called from
|
||||||
|
`PropagationGridWorker` after each forecast hour so all pods have a warm
|
||||||
|
cache by the time clients begin requesting the new hour.
|
||||||
|
|
||||||
|
Returns `{:error, :enoent}` only when neither file exists. A single
|
||||||
|
missing file (HRDPS pre-cycle, HRRR briefly absent) is OK — the cache
|
||||||
|
warms with whichever side is available.
|
||||||
|
"""
|
||||||
|
@spec warm_cache_and_broadcast(non_neg_integer(), DateTime.t()) ::
|
||||||
|
:ok | {:error, :enoent | :invalid_format}
|
||||||
|
def warm_cache_and_broadcast(band_mhz, valid_time) do
|
||||||
|
hrrr = read_score_points(&ScoresFile.read/2, band_mhz, valid_time)
|
||||||
|
hrdps = read_score_points(&ScoresFile.read_hrdps/2, band_mhz, valid_time)
|
||||||
|
|
||||||
|
# Merge priority: HRRR > HRDPS > GEFS. GEFS provides extended-horizon
|
||||||
|
# coverage beyond HRRR's 48h window. Within the f24-f48 overlap, cells
|
||||||
|
# already present in HRRR/HRDPS are skipped so the coarser GEFS scores
|
||||||
|
# don't override the higher-resolution ones.
|
||||||
|
merged = (hrrr || []) ++ (hrdps || [])
|
||||||
|
gefs = read_score_points(&ScoresFile.read_gefs/2, band_mhz, valid_time) || []
|
||||||
|
scores = ScoresFile.merge_preferred(merged, gefs)
|
||||||
|
|
||||||
|
case {hrrr, hrdps, scores} do
|
||||||
|
{nil, nil, []} ->
|
||||||
|
{:error, :enoent}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
ScoreCache.broadcast_put(band_mhz, valid_time, scores)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_score_points(reader, band_mhz, valid_time) do
|
||||||
|
case reader.(band_mhz, valid_time) do
|
||||||
|
{:ok, payload} -> ScoresFile.extract_points(payload, nil)
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_bounds(scores, nil), do: scores
|
||||||
|
|
||||||
|
defp filter_bounds(scores, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
|
||||||
|
Enum.filter(scores, fn %{lat: lat, lon: lon} ->
|
||||||
|
lat >= s and lat <= n and lon >= w and lon <= e
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Get the latest scores for a band (alias for scores_at with earliest valid_time)."
|
||||||
|
@spec latest_scores(non_neg_integer(), %{optional(String.t()) => float()} | nil) ::
|
||||||
|
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
|
||||||
|
def latest_scores(band_mhz, bounds \\ nil) do
|
||||||
|
scores_at(band_mhz, nil, bounds)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp earliest_valid_time(band_mhz) do
|
||||||
|
case ScoresFile.list_valid_times(band_mhz) do
|
||||||
|
[earliest | _] -> earliest
|
||||||
|
[] -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Get scores across all forecast hours for a single grid point (for sparkline)."
|
||||||
|
@spec point_forecast(non_neg_integer(), float(), float()) ::
|
||||||
|
[%{valid_time: DateTime.t(), score: non_neg_integer()}]
|
||||||
|
def point_forecast(band_mhz, lat, lon) do
|
||||||
|
Instrument.span([:propagation, :point_forecast], %{band_mhz: band_mhz}, fn ->
|
||||||
|
{snapped_lat, snapped_lon} = snap_to_grid(lat, lon)
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
|
# Use the on-disk .prop file list as the authoritative timeline so
|
||||||
|
# the chart never falls behind the main-map timeline (which also
|
||||||
|
# reads the disk). The cache is still consulted per-hour for a
|
||||||
|
# fast score lookup; a miss falls through to the file.
|
||||||
|
# Fan the per-hour disk lookups across 4 tasks. Each ScoresFile
|
||||||
|
# read_point is an NFS stat + pread of ~100 bytes (keyed byte at
|
||||||
|
# row*cols+col), so the ceiling is NFS RTT × number of hours —
|
||||||
|
# sequential ran ~4–5× the wall time of the slowest read.
|
||||||
|
band_mhz
|
||||||
|
|> ScoresFile.list_valid_times()
|
||||||
|
|> forecast_window(now)
|
||||||
|
|> Task.async_stream(
|
||||||
|
&point_forecast_entry(band_mhz, &1, snapped_lat, snapped_lon),
|
||||||
|
max_concurrency: 4,
|
||||||
|
ordered: true,
|
||||||
|
timeout: 5_000
|
||||||
|
)
|
||||||
|
|> Enum.flat_map(fn
|
||||||
|
{:ok, nil} ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
{:ok, entry} ->
|
||||||
|
[entry]
|
||||||
|
|
||||||
|
{:exit, reason} ->
|
||||||
|
Logger.error(
|
||||||
|
"Propagation.point_forecast async lookup failed: band_mhz=#{band_mhz} lat=#{snapped_lat} lon=#{snapped_lon} reason=#{inspect(reason)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
[]
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp point_forecast_entry(band_mhz, valid_time, lat, lon) do
|
||||||
|
case ScoreCache.fetch_point(band_mhz, valid_time, lat, lon) do
|
||||||
|
{:ok, score} ->
|
||||||
|
%{valid_time: valid_time, score: score}
|
||||||
|
|
||||||
|
:miss ->
|
||||||
|
case ScoresFile.read_point(band_mhz, valid_time, lat, lon) do
|
||||||
|
nil -> nil
|
||||||
|
score -> %{valid_time: valid_time, score: score}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Select the set of valid_times the forecast chart should render.
|
||||||
|
# Mirrors `available_valid_times`: keep everything from one hour
|
||||||
|
# before now onward so the most recent analysis hour (typically
|
||||||
|
# ~30–60 min behind wall clock due to HRRR publishing lag) sits at
|
||||||
|
# the left edge of the chart as "now". When every hour on disk is
|
||||||
|
# older than that cutoff, fall back to just the newest entry so the
|
||||||
|
# chart can still render a single data point.
|
||||||
|
defp forecast_window([], _now), do: []
|
||||||
|
|
||||||
|
defp forecast_window(times, now) do
|
||||||
|
past_cutoff = DateTime.shift(now, hour: -1)
|
||||||
|
future_cutoff = DateTime.shift(now, hour: @hrrr_forecast_horizon_hours)
|
||||||
|
filter_or_latest(times, past_cutoff, future_cutoff)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp snap_to_grid(lat, lon) do
|
||||||
|
step = Grid.step()
|
||||||
|
{Float.round(Float.round(lat / step) * step, 3), Float.round(Float.round(lon / step) * step, 3)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get the full score and factors for a specific grid point, snapped to
|
||||||
|
the nearest grid cell.
|
||||||
|
|
||||||
|
`:profile_source` describes where the factor breakdown came from:
|
||||||
|
|
||||||
|
* `:exact` — rescored from this `valid_time`'s own profile file.
|
||||||
|
* `{:fallback, fallback_valid_time}` — the requested hour's profile
|
||||||
|
was missing, so we rescored from the most recent analysis profile
|
||||||
|
within the lookback window. Treat as approximate.
|
||||||
|
* `:unavailable` — no profile available; `factors` is `%{}`.
|
||||||
|
"""
|
||||||
|
@spec point_detail(non_neg_integer(), float(), float(), DateTime.t() | nil) ::
|
||||||
|
%{
|
||||||
|
lat: float(),
|
||||||
|
lon: float(),
|
||||||
|
score: non_neg_integer(),
|
||||||
|
factors: map(),
|
||||||
|
profile_source: :exact | {:fallback, DateTime.t()} | :unavailable,
|
||||||
|
valid_time: DateTime.t()
|
||||||
|
}
|
||||||
|
| nil
|
||||||
|
def point_detail(band_mhz, lat, lon, valid_time \\ nil) do
|
||||||
|
Instrument.span([:propagation, :point_detail], %{band_mhz: band_mhz}, fn ->
|
||||||
|
do_point_detail(band_mhz, lat, lon, valid_time)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_point_detail(band_mhz, lat, lon, valid_time) do
|
||||||
|
{snapped_lat, snapped_lon} = snap_to_grid(lat, lon)
|
||||||
|
time = valid_time || latest_valid_time(band_mhz)
|
||||||
|
|
||||||
|
case time do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
case ScoresFile.read_point(band_mhz, time, snapped_lat, snapped_lon) do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
score ->
|
||||||
|
{factors, source} = factors_for(band_mhz, time, snapped_lat, snapped_lon)
|
||||||
|
|
||||||
|
%{
|
||||||
|
lat: snapped_lat,
|
||||||
|
lon: snapped_lon,
|
||||||
|
score: score,
|
||||||
|
factors: factors,
|
||||||
|
profile_source: source,
|
||||||
|
valid_time: time
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Rebuild the factor breakdown for a clicked grid cell by rescoring
|
||||||
|
# the persisted HRRR profile.
|
||||||
|
#
|
||||||
|
# The Rust pipeline (`prop_grid_rs`) writes a per-cell profile file
|
||||||
|
# for every chain step (f00..f18 since Phase 2 cutover), so the
|
||||||
|
# `:exact` branch covers a healthy production state. The fallback to
|
||||||
|
# a recent analysis profile remains as a safety net for missed
|
||||||
|
# cycles or partial chain runs — when it kicks in we tag the result
|
||||||
|
# `{:fallback, profile_valid_time}` so the UI can label the
|
||||||
|
# breakdown as approximated rather than silently misrepresent it.
|
||||||
|
@spec factors_for(non_neg_integer(), DateTime.t(), float(), float()) ::
|
||||||
|
{map(), :exact | {:fallback, DateTime.t()} | :unavailable}
|
||||||
|
defp factors_for(band_mhz, valid_time, lat, lon) do
|
||||||
|
case ProfilesFile.read_point(valid_time, lat, lon) do
|
||||||
|
nil ->
|
||||||
|
factors_from_fallback_profile(band_mhz, valid_time, lat, lon)
|
||||||
|
|
||||||
|
profile ->
|
||||||
|
{Microwaveprop.Propagation.factors_from_profile(band_mhz, valid_time, profile, lat, lon), :exact}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp factors_from_fallback_profile(band_mhz, valid_time, lat, lon) do
|
||||||
|
case latest_profile_time_within_lookback(valid_time) do
|
||||||
|
nil ->
|
||||||
|
{%{}, :unavailable}
|
||||||
|
|
||||||
|
fallback_time ->
|
||||||
|
case ProfilesFile.read_point(fallback_time, lat, lon) do
|
||||||
|
nil ->
|
||||||
|
{%{}, :unavailable}
|
||||||
|
|
||||||
|
profile ->
|
||||||
|
{Microwaveprop.Propagation.factors_from_profile(band_mhz, fallback_time, profile, lat, lon),
|
||||||
|
{:fallback, fallback_time}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp latest_profile_time_within_lookback(%DateTime{} = valid_time) do
|
||||||
|
lookback_cutoff = DateTime.shift(valid_time, hour: -@fallback_profile_lookback_hours)
|
||||||
|
|
||||||
|
ProfilesFile.list_valid_times()
|
||||||
|
|> Enum.filter(fn t ->
|
||||||
|
DateTime.compare(t, valid_time) != :gt and DateTime.compare(t, lookback_cutoff) != :lt
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
[] -> nil
|
||||||
|
past -> Enum.max(past, DateTime)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Get the latest valid_time across all bands."
|
||||||
|
@spec latest_valid_time() :: DateTime.t() | nil
|
||||||
|
def latest_valid_time do
|
||||||
|
ScoresFile.latest_valid_time()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Get the latest valid_time for a specific band."
|
||||||
|
@spec latest_valid_time(non_neg_integer()) :: DateTime.t() | nil
|
||||||
|
def latest_valid_time(band_mhz) do
|
||||||
|
case ScoresFile.list_valid_times(band_mhz) do
|
||||||
|
[] -> nil
|
||||||
|
times -> Enum.max(times, DateTime)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
|
||||||
|
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
|
|
@ -110,15 +110,15 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
|> cast(attrs, @submission_fields)
|
|> cast(attrs, @submission_fields)
|
||||||
|> validate_required(@submission_required)
|
|> validate_required(@submission_required)
|
||||||
|> validate_user_or_email()
|
|> validate_user_or_email()
|
||||||
|> sanitize_callsign(:station1)
|
|> MCH.normalize_callsign(:station1)
|
||||||
|> sanitize_callsign(:station2)
|
|> MCH.normalize_callsign(:station2)
|
||||||
|> sanitize_grid(:grid1)
|
|> update_change(:grid1, &MCH.normalize_grid/1)
|
||||||
|> sanitize_grid(:grid2)
|
|> update_change(:grid2, &MCH.normalize_grid/1)
|
||||||
|> normalize_blank_mode()
|
|> normalize_blank_mode()
|
||||||
|> validate_callsign(:station1)
|
|> validate_callsign(:station1)
|
||||||
|> validate_callsign(:station2)
|
|> validate_callsign(:station2)
|
||||||
|> validate_grid_format(:grid1)
|
|> MCH.validate_grid(:grid1, blank_error: false)
|
||||||
|> validate_grid_format(:grid2)
|
|> MCH.validate_grid(:grid2, blank_error: false)
|
||||||
|> validate_mode_inclusion()
|
|> validate_mode_inclusion()
|
||||||
|> validate_inclusion(:band, @allowed_bands)
|
|> validate_inclusion(:band, @allowed_bands)
|
||||||
|> validate_email_format()
|
|> validate_email_format()
|
||||||
|
|
@ -204,34 +204,8 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
end
|
end
|
||||||
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)
|
# Callsigns: letters, digits, and / only (e.g. W5XD, KG5CCI/P, VE3/W5XD)
|
||||||
defp validate_callsign(changeset, field) do
|
defp validate_callsign(changeset, field) do
|
||||||
validate_format(changeset, field, ~r/^[A-Z0-9\/]+$/, message: "must contain only letters, digits, and /")
|
validate_format(changeset, field, ~r/^[A-Z0-9\/]+$/, message: "must contain only letters, digits, and /")
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
156
lib/microwaveprop/radio/maidenhead_changeset_helpers.ex
Normal file
156
lib/microwaveprop/radio/maidenhead_changeset_helpers.ex
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
defmodule Microwaveprop.Radio.MaidenheadChangesetHelpers do
|
||||||
|
@moduledoc """
|
||||||
|
Shared changeset helpers for Maidenhead grid squares, callsigns, and
|
||||||
|
lat/lon coordinate validation. Used across multiple domain schemas
|
||||||
|
(Beacon, User, FixedStation, Contact, Location, BeaconMonitor).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Trims whitespace and uppercases a callsign field if a change is present.
|
||||||
|
Returns the changeset unchanged when the field has no change or the
|
||||||
|
change is `nil`.
|
||||||
|
"""
|
||||||
|
@spec normalize_callsign(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
|
||||||
|
def normalize_callsign(changeset, field) do
|
||||||
|
update_change(changeset, field, fn
|
||||||
|
nil -> nil
|
||||||
|
val when is_binary(val) -> val |> String.trim() |> String.upcase()
|
||||||
|
val -> val
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Validates a Maidenhead grid field on the changeset.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
* `:blank_error` — when `true` (default), a `nil` or empty-string value
|
||||||
|
adds a "can't be blank" error. When `false`, blank values pass through
|
||||||
|
silently.
|
||||||
|
* `:normalizer` — a function of one argument called to normalize the
|
||||||
|
grid when it passes validation. Defaults to `normalize_grid/1`.
|
||||||
|
"""
|
||||||
|
@spec validate_grid(Ecto.Changeset.t(), atom(), keyword()) :: Ecto.Changeset.t()
|
||||||
|
def validate_grid(changeset, field, opts \\ []) do
|
||||||
|
blank_error = Keyword.get(opts, :blank_error, true)
|
||||||
|
normalizer = Keyword.get(opts, :normalizer, &normalize_grid/1)
|
||||||
|
|
||||||
|
case get_field(changeset, field) do
|
||||||
|
nil ->
|
||||||
|
if blank_error do
|
||||||
|
add_error(changeset, field, "can't be blank")
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
|
||||||
|
"" ->
|
||||||
|
if blank_error do
|
||||||
|
add_error(changeset, field, "can't be blank")
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
|
||||||
|
value ->
|
||||||
|
if Maidenhead.valid?(value) do
|
||||||
|
update_change(changeset, field, normalizer)
|
||||||
|
else
|
||||||
|
add_error(changeset, field, "is not a valid Maidenhead grid")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Normalizes a Maidenhead grid string to standard form.
|
||||||
|
|
||||||
|
* `nil` → `nil`
|
||||||
|
* Trims leading and trailing whitespace.
|
||||||
|
* For grids of 6 or more characters: uppercases the first 4 characters
|
||||||
|
(field + square) and downcases the rest (subsquares and beyond).
|
||||||
|
* For grids of fewer than 6 characters: uppercases the entire string.
|
||||||
|
"""
|
||||||
|
@spec normalize_grid(nil | String.t()) :: nil | String.t()
|
||||||
|
def normalize_grid(nil), do: nil
|
||||||
|
|
||||||
|
def normalize_grid(grid) when is_binary(grid) do
|
||||||
|
trimmed = String.trim(grid)
|
||||||
|
|
||||||
|
if String.length(trimmed) >= 6 do
|
||||||
|
String.upcase(String.slice(trimmed, 0, 4)) <> String.downcase(String.slice(trimmed, 4, String.length(trimmed) - 4))
|
||||||
|
else
|
||||||
|
String.upcase(trimmed)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Derives lat/lon coordinates from a Maidenhead grid field when lat/lon
|
||||||
|
are not already set. Only runs when the changeset is valid.
|
||||||
|
|
||||||
|
Uses the centre point of the grid square, rounded to 6 decimal places.
|
||||||
|
"""
|
||||||
|
@spec derive_latlon(
|
||||||
|
Ecto.Changeset.t(),
|
||||||
|
atom(),
|
||||||
|
atom(),
|
||||||
|
atom()
|
||||||
|
) :: Ecto.Changeset.t()
|
||||||
|
def derive_latlon(changeset, grid_field \\ :grid, lat_field \\ :lat, lon_field \\ :lon) do
|
||||||
|
grid = get_field(changeset, grid_field)
|
||||||
|
lat = get_field(changeset, lat_field)
|
||||||
|
lon = get_field(changeset, lon_field)
|
||||||
|
|
||||||
|
if changeset.valid? and is_binary(grid) and grid != "" and
|
||||||
|
(not is_number(lat) or not is_number(lon)) do
|
||||||
|
case Maidenhead.to_latlon(grid) do
|
||||||
|
{:ok, {derived_lat, derived_lon}} ->
|
||||||
|
changeset
|
||||||
|
|> put_change(lat_field, Float.round(derived_lat * 1.0, 6))
|
||||||
|
|> put_change(lon_field, Float.round(derived_lon * 1.0, 6))
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Derives a Maidenhead grid from lat/lon coordinates when the grid field
|
||||||
|
is blank and lat/lon are numbers.
|
||||||
|
|
||||||
|
The `precision` must be an even number between 4 and 10 (default 6).
|
||||||
|
"""
|
||||||
|
@spec derive_grid(
|
||||||
|
Ecto.Changeset.t(),
|
||||||
|
atom(),
|
||||||
|
atom(),
|
||||||
|
atom(),
|
||||||
|
pos_integer()
|
||||||
|
) :: Ecto.Changeset.t()
|
||||||
|
def derive_grid(changeset, grid_field \\ :grid, lat_field \\ :lat, lon_field \\ :lon, precision \\ 6) do
|
||||||
|
grid = get_field(changeset, grid_field)
|
||||||
|
lat = get_field(changeset, lat_field)
|
||||||
|
lon = get_field(changeset, lon_field)
|
||||||
|
|
||||||
|
if grid in [nil, ""] and is_number(lat) and is_number(lon) do
|
||||||
|
put_change(changeset, grid_field, Maidenhead.from_latlon(lat * 1.0, lon * 1.0, precision))
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Validates that latitude is between -90 and 90 and longitude is between
|
||||||
|
-180 and 180.
|
||||||
|
"""
|
||||||
|
@spec validate_latlon(Ecto.Changeset.t(), atom(), atom()) :: Ecto.Changeset.t()
|
||||||
|
def validate_latlon(changeset, lat_field \\ :lat, lon_field \\ :lon) do
|
||||||
|
changeset
|
||||||
|
|> validate_number(lat_field, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|
||||||
|
|> validate_number(lon_field, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -11,6 +11,7 @@ defmodule Microwaveprop.Rover do
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.ContextHelpers
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Rover.FixedStation
|
alias Microwaveprop.Rover.FixedStation
|
||||||
|
|
@ -56,7 +57,7 @@ defmodule Microwaveprop.Rover do
|
||||||
@spec update_station(User.t(), Ecto.UUID.t(), map()) ::
|
@spec update_station(User.t(), Ecto.UUID.t(), map()) ::
|
||||||
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def update_station(%User{} = user, id, attrs) do
|
def update_station(%User{} = user, id, attrs) do
|
||||||
case fetch_owned(user, id) do
|
case ContextHelpers.fetch_owned(FixedStation, id, user) do
|
||||||
{:ok, station} ->
|
{:ok, station} ->
|
||||||
station
|
station
|
||||||
|> FixedStation.changeset(attrs)
|
|> FixedStation.changeset(attrs)
|
||||||
|
|
@ -70,7 +71,7 @@ defmodule Microwaveprop.Rover do
|
||||||
@spec delete_station(User.t(), Ecto.UUID.t()) ::
|
@spec delete_station(User.t(), Ecto.UUID.t()) ::
|
||||||
{:ok, FixedStation.t()} | {:error, :not_found}
|
{:ok, FixedStation.t()} | {:error, :not_found}
|
||||||
def delete_station(%User{} = user, id) do
|
def delete_station(%User{} = user, id) do
|
||||||
case fetch_owned(user, id) do
|
case ContextHelpers.fetch_owned(FixedStation, id, user) do
|
||||||
{:ok, station} ->
|
{:ok, station} ->
|
||||||
Repo.delete(station)
|
Repo.delete(station)
|
||||||
|
|
||||||
|
|
@ -82,7 +83,7 @@ defmodule Microwaveprop.Rover do
|
||||||
@spec toggle_selected(User.t(), Ecto.UUID.t()) ::
|
@spec toggle_selected(User.t(), Ecto.UUID.t()) ::
|
||||||
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def toggle_selected(%User{} = user, id) do
|
def toggle_selected(%User{} = user, id) do
|
||||||
case fetch_owned(user, id) do
|
case ContextHelpers.fetch_owned(FixedStation, id, user) do
|
||||||
{:ok, station} ->
|
{:ok, station} ->
|
||||||
station
|
station
|
||||||
|> Ecto.Changeset.change(selected: not station.selected)
|
|> Ecto.Changeset.change(selected: not station.selected)
|
||||||
|
|
@ -117,13 +118,6 @@ defmodule Microwaveprop.Rover do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_owned(%User{id: user_id}, id) do
|
|
||||||
case Repo.get(FixedStation, id) do
|
|
||||||
%FixedStation{user_id: ^user_id} = station -> {:ok, station}
|
|
||||||
_ -> {:error, :not_found}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ── Rover locations (globally visible, logged-in to mutate) ──────────
|
# ── Rover locations (globally visible, logged-in to mutate) ──────────
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -148,7 +142,7 @@ defmodule Microwaveprop.Rover do
|
||||||
@spec update_location(User.t(), Ecto.UUID.t(), map()) ::
|
@spec update_location(User.t(), Ecto.UUID.t(), map()) ::
|
||||||
{:ok, Location.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
{:ok, Location.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def update_location(%User{} = user, id, attrs) do
|
def update_location(%User{} = user, id, attrs) do
|
||||||
case fetch_owned_location(user, id) do
|
case ContextHelpers.fetch_owned(Location, id, user) do
|
||||||
{:ok, location} ->
|
{:ok, location} ->
|
||||||
location
|
location
|
||||||
|> Location.changeset(attrs)
|
|> Location.changeset(attrs)
|
||||||
|
|
@ -162,26 +156,12 @@ defmodule Microwaveprop.Rover do
|
||||||
@spec delete_location(User.t(), Ecto.UUID.t()) ::
|
@spec delete_location(User.t(), Ecto.UUID.t()) ::
|
||||||
{:ok, Location.t()} | {:error, :not_found}
|
{:ok, Location.t()} | {:error, :not_found}
|
||||||
def delete_location(%User{} = user, id) do
|
def delete_location(%User{} = user, id) do
|
||||||
case fetch_owned_location(user, id) do
|
case ContextHelpers.fetch_owned(Location, id, user) do
|
||||||
{:ok, location} -> Repo.delete(location)
|
{:ok, location} -> Repo.delete(location)
|
||||||
{:error, :not_found} -> {:error, :not_found}
|
{:error, :not_found} -> {:error, :not_found}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_owned_location(%User{is_admin: true}, id) do
|
|
||||||
case Repo.get(Location, id) do
|
|
||||||
%Location{} = loc -> {:ok, loc}
|
|
||||||
_ -> {:error, :not_found}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp fetch_owned_location(%User{id: user_id}, id) do
|
|
||||||
case Repo.get(Location, id) do
|
|
||||||
%Location{user_id: ^user_id} = loc -> {:ok, loc}
|
|
||||||
_ -> {:error, :not_found}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp next_position(%User{id: user_id}) do
|
defp next_position(%User{id: user_id}) do
|
||||||
query =
|
query =
|
||||||
from s in FixedStation,
|
from s in FixedStation,
|
||||||
|
|
@ -198,14 +178,7 @@ defmodule Microwaveprop.Rover do
|
||||||
|
|
||||||
defp maybe_enqueue_elevation(other), do: other
|
defp maybe_enqueue_elevation(other), do: other
|
||||||
|
|
||||||
# Wrapped behind a function-exported guard so this module compiles
|
|
||||||
# cleanly while Task 8's worker is still being authored — the worker
|
|
||||||
# is referenced by atom and resolved at runtime once it's loaded.
|
|
||||||
defp enqueue_station_elevation(id) do
|
defp enqueue_station_elevation(id) do
|
||||||
worker = Microwaveprop.Workers.StationElevationWorker
|
ContextHelpers.safe_enqueue(Microwaveprop.Workers.StationElevationWorker, %{id: id})
|
||||||
|
|
||||||
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
|
|
||||||
%{id: id} |> worker.new() |> Oban.insert()
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ defmodule Microwaveprop.Rover.FixedStation do
|
||||||
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
|
||||||
|
|
||||||
@callsign_regex ~r/^[A-Z0-9\/]{3,12}$/
|
@callsign_regex ~r/^[A-Z0-9\/]{3,12}$/
|
||||||
|
|
||||||
|
|
@ -40,29 +40,18 @@ defmodule Microwaveprop.Rover.FixedStation do
|
||||||
def changeset(station, attrs) do
|
def changeset(station, attrs) do
|
||||||
station
|
station
|
||||||
|> cast(attrs, [:callsign, :grid, :lat, :lon, :elevation_m, :selected, :position])
|
|> cast(attrs, [:callsign, :grid, :lat, :lon, :elevation_m, :selected, :position])
|
||||||
|> normalize_callsign()
|
|> MCH.normalize_callsign(:callsign)
|
||||||
|> normalize_grid()
|
|> normalize_grid()
|
||||||
|> validate_required([:callsign])
|
|> validate_required([:callsign])
|
||||||
|> validate_format(:callsign, @callsign_regex)
|
|> validate_format(:callsign, @callsign_regex)
|
||||||
|> validate_grid_format()
|
|> MCH.validate_grid(:grid, blank_error: false, normalizer: &normalize_grid_string/1)
|
||||||
|> derive_latlon_from_grid()
|
|> MCH.derive_latlon(:grid, :lat, :lon)
|
||||||
|> validate_required([:lat, :lon])
|
|> validate_required([:lat, :lon])
|
||||||
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|
|> MCH.validate_latlon(:lat, :lon)
|
||||||
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|
|
||||||
|> foreign_key_constraint(:user_id)
|
|> foreign_key_constraint(:user_id)
|
||||||
|> unique_constraint(:callsign, name: :fixed_stations_user_id_callsign_index)
|
|> unique_constraint(:callsign, name: :fixed_stations_user_id_callsign_index)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_callsign(changeset) do
|
|
||||||
case get_change(changeset, :callsign) do
|
|
||||||
nil ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
call when is_binary(call) ->
|
|
||||||
put_change(changeset, :callsign, call |> String.trim() |> String.upcase())
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp normalize_grid(changeset) do
|
defp normalize_grid(changeset) do
|
||||||
case get_change(changeset, :grid) do
|
case get_change(changeset, :grid) do
|
||||||
nil -> changeset
|
nil -> changeset
|
||||||
|
|
@ -93,46 +82,4 @@ defmodule Microwaveprop.Rover.FixedStation do
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp validate_grid_format(changeset) do
|
|
||||||
case get_field(changeset, :grid) do
|
|
||||||
nil ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
grid ->
|
|
||||||
if Maidenhead.valid?(grid) do
|
|
||||||
changeset
|
|
||||||
else
|
|
||||||
add_error(changeset, :grid, "is not a valid Maidenhead grid")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp derive_latlon_from_grid(changeset) do
|
|
||||||
lat = get_field(changeset, :lat)
|
|
||||||
lon = get_field(changeset, :lon)
|
|
||||||
grid = get_field(changeset, :grid)
|
|
||||||
|
|
||||||
cond do
|
|
||||||
not changeset.valid? ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
is_number(lat) and is_number(lon) ->
|
|
||||||
changeset
|
|
||||||
|
|
||||||
is_binary(grid) ->
|
|
||||||
case Maidenhead.to_latlon(grid) do
|
|
||||||
{:ok, {derived_lat, derived_lon}} ->
|
|
||||||
changeset
|
|
||||||
|> put_change(:lat, derived_lat)
|
|
||||||
|> put_change(:lon, derived_lon)
|
|
||||||
|
|
||||||
:error ->
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
|
|
||||||
true ->
|
|
||||||
changeset
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ defmodule Microwaveprop.Rover.Location do
|
||||||
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
|
||||||
|
|
||||||
@statuses [:good, :bad]
|
@statuses [:good, :bad]
|
||||||
|
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
|
@ -36,8 +38,7 @@ defmodule Microwaveprop.Rover.Location do
|
||||||
location
|
location
|
||||||
|> cast(attrs, [:lat, :lon, :status, :notes])
|
|> cast(attrs, [:lat, :lon, :status, :notes])
|
||||||
|> validate_required([:lat, :lon, :status])
|
|> validate_required([:lat, :lon, :status])
|
||||||
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|
|> MCH.validate_latlon(:lat, :lon)
|
||||||
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|
|
||||||
|> validate_inclusion(:status, @statuses)
|
|> validate_inclusion(:status, @statuses)
|
||||||
|> validate_length(:notes, max: 4000)
|
|> validate_length(:notes, max: 4000)
|
||||||
|> foreign_key_constraint(:user_id)
|
|> foreign_key_constraint(:user_id)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule Microwaveprop.RoverPlanning do
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.ContextHelpers
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Rover.Location
|
alias Microwaveprop.Rover.Location
|
||||||
alias Microwaveprop.RoverPlanning.Mission
|
alias Microwaveprop.RoverPlanning.Mission
|
||||||
|
|
@ -81,7 +82,7 @@ defmodule Microwaveprop.RoverPlanning do
|
||||||
@spec update_mission(User.t(), Ecto.UUID.t(), map()) ::
|
@spec update_mission(User.t(), Ecto.UUID.t(), map()) ::
|
||||||
{:ok, Mission.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
{:ok, Mission.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def update_mission(%User{} = user, id, attrs) do
|
def update_mission(%User{} = user, id, attrs) do
|
||||||
case fetch_owned(user, id) do
|
case ContextHelpers.fetch_owned(Mission, id, user) do
|
||||||
{:ok, mission} ->
|
{:ok, mission} ->
|
||||||
mission
|
mission
|
||||||
|> Repo.preload(stations: from(s in Station, order_by: s.position))
|
|> Repo.preload(stations: from(s in Station, order_by: s.position))
|
||||||
|
|
@ -105,7 +106,7 @@ defmodule Microwaveprop.RoverPlanning do
|
||||||
@spec delete_mission(User.t(), Ecto.UUID.t()) ::
|
@spec delete_mission(User.t(), Ecto.UUID.t()) ::
|
||||||
{:ok, Mission.t()} | {:error, :not_found}
|
{:ok, Mission.t()} | {:error, :not_found}
|
||||||
def delete_mission(%User{} = user, id) do
|
def delete_mission(%User{} = user, id) do
|
||||||
case fetch_owned(user, id) do
|
case ContextHelpers.fetch_owned(Mission, id, user) do
|
||||||
{:ok, mission} -> Repo.delete(mission)
|
{:ok, mission} -> Repo.delete(mission)
|
||||||
{:error, :not_found} -> {:error, :not_found}
|
{:error, :not_found} -> {:error, :not_found}
|
||||||
end
|
end
|
||||||
|
|
@ -305,18 +306,4 @@ defmodule Microwaveprop.RoverPlanning do
|
||||||
_ = Oban.insert_all(changesets)
|
_ = Oban.insert_all(changesets)
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_owned(%User{is_admin: true}, id) do
|
|
||||||
case get_mission(id) do
|
|
||||||
nil -> {:error, :not_found}
|
|
||||||
mission -> {:ok, mission}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp fetch_owned(%User{id: user_id}, id) do
|
|
||||||
case get_mission(id) do
|
|
||||||
%Mission{user_id: ^user_id} = mission -> {:ok, mission}
|
|
||||||
_ -> {:error, :not_found}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
|
||||||
@moduledoc "Shared new/edit form for beacon records, used by `BeaconLive.Index`."
|
@moduledoc "Shared new/edit form for beacon records, used by `BeaconLive.Index`."
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
|
||||||
|
|
||||||
alias Microwaveprop.Beacons
|
alias Microwaveprop.Beacons
|
||||||
alias Microwaveprop.Beacons.Beacon
|
alias Microwaveprop.Beacons.Beacon
|
||||||
|
|
||||||
|
|
@ -158,9 +160,6 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp current_user(%{user: user}), do: user
|
|
||||||
defp current_user(_), do: nil
|
|
||||||
|
|
||||||
defp return_path("index", _beacon), do: ~p"/beacons"
|
defp return_path("index", _beacon), do: ~p"/beacons"
|
||||||
defp return_path("show", beacon), do: ~p"/beacons/#{beacon}"
|
defp return_path("show", beacon), do: ~p"/beacons/#{beacon}"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
import MicrowavepropWeb.Components.SkewTChart
|
import MicrowavepropWeb.Components.SkewTChart
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1, subscribe: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
alias Microwaveprop.Propagation.PathAnalysis
|
alias Microwaveprop.Propagation.PathAnalysis
|
||||||
|
|
@ -83,7 +84,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")
|
subscribe(socket, "contact_enrichment:#{contact.id}")
|
||||||
|
|
||||||
kickoff_hydration(socket, contact)
|
kickoff_hydration(socket, contact)
|
||||||
else
|
else
|
||||||
|
|
@ -215,9 +216,6 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp current_user(%{current_scope: %{user: %{} = user}}), do: user
|
|
||||||
defp current_user(_), do: nil
|
|
||||||
|
|
||||||
defp admin?(%{current_scope: %{user: %{is_admin: true}}}), do: true
|
defp admin?(%{current_scope: %{user: %{is_admin: true}}}), do: true
|
||||||
defp admin?(_), do: false
|
defp admin?(_), do: false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ defmodule MicrowavepropWeb.EmeLive do
|
||||||
"""
|
"""
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2]
|
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2, assign_url_params: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Moon
|
alias Microwaveprop.Moon
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
|
|
@ -64,7 +64,7 @@ defmodule MicrowavepropWeb.EmeLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
p = Map.merge(@defaults, Map.take(params, Map.keys(@defaults)))
|
p = assign_url_params(params, @defaults)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
assign(socket,
|
assign(socket,
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ defmodule MicrowavepropWeb.ImportLive do
|
||||||
"""
|
"""
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Radio.ImportRun
|
alias Microwaveprop.Radio.ImportRun
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
@pubsub Microwaveprop.PubSub
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
case load_run(id) do
|
case load_run(id) do
|
||||||
|
|
@ -24,9 +24,7 @@ defmodule MicrowavepropWeb.ImportLive do
|
||||||
defp mount_authorized(socket, run) do
|
defp mount_authorized(socket, run) do
|
||||||
case authorized_to_view?(socket, run) do
|
case authorized_to_view?(socket, run) do
|
||||||
:ok ->
|
:ok ->
|
||||||
if connected?(socket) do
|
subscribe(socket, "csv_import:#{run.id}")
|
||||||
Phoenix.PubSub.subscribe(@pubsub, "csv_import:#{run.id}")
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
use LiveStash, stored_keys: [:selected_band, :selected_time]
|
use LiveStash, stored_keys: [:selected_band, :selected_time]
|
||||||
|
|
||||||
import MicrowavepropWeb.Layouts, only: [admin_links: 1]
|
import MicrowavepropWeb.Layouts, only: [admin_links: 1]
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Propagation
|
alias Microwaveprop.Propagation
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
|
|
@ -97,8 +98,8 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
|
|
||||||
defp subscribe_if_connected(socket) do
|
defp subscribe_if_connected(socket) do
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
subscribe(socket, "propagation:updated")
|
||||||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
subscribe(socket, "propagation:pipeline")
|
||||||
socket = schedule_pipeline_status_timer(socket)
|
socket = schedule_pipeline_status_timer(socket)
|
||||||
schedule_advance_timer(socket)
|
schedule_advance_timer(socket)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
import MicrowavepropWeb.Components.SkewTChart
|
import MicrowavepropWeb.Components.SkewTChart
|
||||||
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2]
|
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2, subscribe: 2, assign_url_params: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
||||||
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
|
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
|
||||||
|
|
@ -31,10 +31,7 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
_ =
|
subscribe(socket, "propagation:updated")
|
||||||
if connected?(socket) do
|
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
|
|
@ -101,7 +98,7 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
p = Map.merge(@defaults, Map.take(params, @url_params))
|
p = assign_url_params(params, @defaults)
|
||||||
|
|
||||||
is_gps = p["source"] == "gps"
|
is_gps = p["source"] == "gps"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
|
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
import MicrowavepropWeb.LiveHelpers, only: [parse_int: 2]
|
import MicrowavepropWeb.LiveHelpers, only: [parse_int: 2, current_user: 1]
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
||||||
|
|
@ -160,13 +160,6 @@ defmodule MicrowavepropWeb.RoverLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp current_user(socket) do
|
|
||||||
case socket.assigns[:current_scope] do
|
|
||||||
%{user: %User{} = user} -> user
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp home_label(lat, lon), do: Maidenhead.from_latlon(lat, lon, 10)
|
defp home_label(lat, lon), do: Maidenhead.from_latlon(lat, lon, 10)
|
||||||
|
|
||||||
defp drive_radius_km(max_distance_mi), do: max_distance_mi * @km_per_mi
|
defp drive_radius_km(max_distance_mi), do: max_distance_mi * @km_per_mi
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
|
||||||
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Rover.Location
|
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Rover.Location
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
|
|
@ -258,9 +259,6 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
|
||||||
value |> Float.round(6) |> Float.to_string()
|
value |> Float.round(6) |> Float.to_string()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns)
|
|
||||||
defp current_user(assigns) when is_map(assigns), do: scope_user(assigns)
|
|
||||||
|
|
||||||
defp scope_user(assigns) do
|
defp scope_user(assigns) do
|
||||||
case assigns[:current_scope] do
|
case assigns[:current_scope] do
|
||||||
%{user: %User{} = user} -> user
|
%{user: %User{} = user} -> user
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.RoverPlanning.Mission
|
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.RoverPlanning.Mission
|
||||||
|
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.RoverPlanning
|
alias Microwaveprop.RoverPlanning
|
||||||
alias Microwaveprop.RoverPlanning.Mission
|
alias Microwaveprop.RoverPlanning.Mission
|
||||||
|
|
@ -79,16 +81,6 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
|
||||||
|> assign(:options, updated_options)
|
|> assign(:options, updated_options)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns)
|
|
||||||
defp current_user(assigns) when is_map(assigns), do: scope_user(assigns)
|
|
||||||
|
|
||||||
defp scope_user(assigns) do
|
|
||||||
case assigns[:current_scope] do
|
|
||||||
%{user: %User{} = user} -> user
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp can_modify?(scope, %{user_id: user_id}) do
|
defp can_modify?(scope, %{user_id: user_id}) do
|
||||||
case scope do
|
case scope do
|
||||||
%{user: %User{is_admin: true}} -> true
|
%{user: %User{is_admin: true}} -> true
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
|
||||||
@moduledoc "New / edit form for `/rover-planning` missions."
|
@moduledoc "New / edit form for `/rover-planning` missions."
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.RoverPlanning
|
alias Microwaveprop.RoverPlanning
|
||||||
alias Microwaveprop.RoverPlanning.Mission
|
alias Microwaveprop.RoverPlanning.Mission
|
||||||
|
|
@ -198,13 +200,6 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
|
||||||
|> Kernel.+(1)
|
|> Kernel.+(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
|
|
||||||
case assigns[:current_scope] do
|
|
||||||
%{user: %User{} = user} -> user
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp can_modify?(%User{is_admin: true}, _), do: true
|
defp can_modify?(%User{is_admin: true}, _), do: true
|
||||||
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
|
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
|
||||||
defp can_modify?(_, _), do: false
|
defp can_modify?(_, _), do: false
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
"""
|
"""
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
alias Microwaveprop.Rover
|
alias Microwaveprop.Rover
|
||||||
|
|
@ -24,10 +26,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
|> push_navigate(to: ~p"/rover-planning")}
|
|> push_navigate(to: ~p"/rover-planning")}
|
||||||
|
|
||||||
%Mission{} = mission ->
|
%Mission{} = mission ->
|
||||||
_ =
|
subscribe(socket, "rover_planning:#{mission.id}")
|
||||||
if connected?(socket) do
|
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.StatusLive do
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Cache
|
alias Microwaveprop.Cache
|
||||||
alias Microwaveprop.Format
|
alias Microwaveprop.Format
|
||||||
|
|
@ -15,16 +16,13 @@ defmodule MicrowavepropWeb.StatusLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
_ =
|
# Rust prop-grid-rs emits NOTIFY propagation_ready on completion —
|
||||||
if connected?(socket) do
|
# PropagationNotifyListener fans it out as `propagation:updated` so
|
||||||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:contact_status")
|
# the grid_tasks panel transitions from "running" → "done" without
|
||||||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:oban_jobs")
|
# a single refresh.
|
||||||
# Rust prop-grid-rs emits NOTIFY propagation_ready on completion —
|
subscribe(socket, "db:contact_status")
|
||||||
# PropagationNotifyListener fans it out as `propagation:updated` so
|
subscribe(socket, "db:oban_jobs")
|
||||||
# the grid_tasks panel transitions from "running" → "done" without
|
subscribe(socket, "propagation:updated")
|
||||||
# a single refresh.
|
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
|
||||||
end
|
|
||||||
|
|
||||||
all = fetch_all_stats()
|
all = fetch_all_stats()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
use LiveStash, stored_keys: [:active_tab]
|
use LiveStash, stored_keys: [:active_tab]
|
||||||
|
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
|
||||||
|
import MicrowavepropWeb.SubmitLive.AdifUploadComponent
|
||||||
|
import MicrowavepropWeb.SubmitLive.CsvUploadComponent
|
||||||
|
import MicrowavepropWeb.SubmitLive.PreviewComponent
|
||||||
|
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
alias Microwaveprop.Radio
|
alias Microwaveprop.Radio
|
||||||
alias Microwaveprop.Radio.AdifImport
|
alias Microwaveprop.Radio.AdifImport
|
||||||
|
|
@ -270,11 +275,6 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp error_to_string(:too_large), do: "File is too large"
|
|
||||||
defp error_to_string(:too_many_files), do: "Only one file allowed"
|
|
||||||
defp error_to_string(:not_accepted), do: "File type not accepted"
|
|
||||||
defp error_to_string(other), do: to_string(other)
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -359,10 +359,6 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
# In-template variant — takes an assigns map directly (not a socket).
|
|
||||||
defp current_user(%{current_scope: %{user: %{} = user}}), do: user
|
|
||||||
defp current_user(_), do: nil
|
|
||||||
|
|
||||||
defp single_contact_form(assigns) do
|
defp single_contact_form(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4">
|
<.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4">
|
||||||
|
|
@ -475,470 +471,4 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
</.form>
|
</.form>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp csv_upload_form(assigns) do
|
|
||||||
~H"""
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div class="alert text-sm">
|
|
||||||
<div>
|
|
||||||
<p class="mb-2">Upload a CSV file with multiple contacts. Columns:</p>
|
|
||||||
<code class="text-xs">
|
|
||||||
station1, station2, grid1, grid2, band, mode, qso_timestamp, notes
|
|
||||||
</code>
|
|
||||||
<ul class="list-disc list-outside pl-5 mt-2 space-y-1 text-base-content/60">
|
|
||||||
<li>
|
|
||||||
Timestamps in most formats accepted (e.g. <code>2024-06-15T14:30:00Z</code>, <code>6/15/2024 2:30 PM</code>, <code>2024-06-15 14:30</code>). All times assumed UTC.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
|
|
||||||
</li>
|
|
||||||
<li>Band in MHz (e.g. 10000, 24000)</li>
|
|
||||||
<li>
|
|
||||||
Mode is <strong>optional</strong> — omit the <code>mode</code> column entirely
|
|
||||||
(6 columns total) or leave its value blank.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Notes is <strong>optional</strong> — free-form operator commentary up to
|
|
||||||
2000 characters. Leave the cell blank to store NULL.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p class="mt-2">
|
|
||||||
<a href="/downloads/sample_contacts.csv" download class="link link-primary">
|
|
||||||
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="csv-upload-form" phx-submit="upload_csv" phx-change="validate_csv" class="space-y-4">
|
|
||||||
<div class="fieldset mb-2">
|
|
||||||
<label>
|
|
||||||
<span class="label mb-1">CSV File</span>
|
|
||||||
<.live_file_input upload={@uploads.csv} class="file-input file-input-bordered w-full" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :for={entry <- @uploads.csv.entries} class="space-y-1">
|
|
||||||
<div class="flex items-center justify-between text-xs tabular-nums">
|
|
||||||
<span class="truncate pr-2">{entry.client_name}</span>
|
|
||||||
<span class="flex items-center gap-2 shrink-0">
|
|
||||||
<span :if={uploading?(entry)}>{entry.progress}%</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
phx-click="cancel_csv_upload"
|
|
||||||
phx-value-ref={entry.ref}
|
|
||||||
aria-label="Cancel upload"
|
|
||||||
>
|
|
||||||
<.icon name="hero-x-mark" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
:if={uploading?(entry)}
|
|
||||||
class="progress progress-primary w-full"
|
|
||||||
value={entry.progress}
|
|
||||||
max="100"
|
|
||||||
></progress>
|
|
||||||
<div :for={err <- upload_errors(@uploads.csv, entry)} class="text-xs text-error">
|
|
||||||
{error_to_string(err)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :for={err <- upload_errors(@uploads.csv)} class="text-xs text-error">
|
|
||||||
{error_to_string(err)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if @current_user do %>
|
|
||||||
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer mt-2">
|
|
||||||
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
|
|
||||||
<span class="text-sm">Private — only visible to me and administrators</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
|
||||||
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp adif_upload_form(assigns) do
|
|
||||||
assigns = assign(assigns, :mode_mapping, AdifImport.mode_mapping_reference())
|
|
||||||
|
|
||||||
~H"""
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div class="alert text-sm">
|
|
||||||
<div>
|
|
||||||
<p class="mb-2">
|
|
||||||
Upload an ADIF (.adi / .adif) file exported from your logging program.
|
|
||||||
</p>
|
|
||||||
<ul class="list-disc list-outside pl-5 mt-2 space-y-1 text-base-content/60">
|
|
||||||
<li>
|
|
||||||
Required fields: <code>CALL</code>, <code>STATION_CALLSIGN</code>
|
|
||||||
(or <code>OPERATOR</code>), <code>GRIDSQUARE</code>, <code>MY_GRIDSQUARE</code>, <code>QSO_DATE</code>, <code>TIME_ON</code>,
|
|
||||||
and <code>FREQ</code>
|
|
||||||
or <code>BAND</code>
|
|
||||||
</li>
|
|
||||||
<li>Contacts on amateur bands from 50 MHz and up will be imported</li>
|
|
||||||
<li>HF contacts (below 50 MHz) are silently skipped</li>
|
|
||||||
<li>Frequencies are fuzzy-matched to the nearest amateur band</li>
|
|
||||||
<li>
|
|
||||||
Operator commentary from <code>NOTES</code>
|
|
||||||
is carried over (falling back to <code>COMMENT</code>)
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert text-sm">
|
|
||||||
<div class="w-full">
|
|
||||||
<p class="font-semibold mb-2">Mode handling</p>
|
|
||||||
<p class="mb-2 text-base-content/70">
|
|
||||||
ADIF modes are normalized to the six modes we track. SUBMODE takes
|
|
||||||
precedence when present (e.g. <code>MODE=MFSK, SUBMODE=FT8</code> imports as FT8).
|
|
||||||
</p>
|
|
||||||
<table class="table table-xs mt-2">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ADIF value</th>
|
|
||||||
<th>Stored as</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr :for={{adif, mapped} <- @mode_mapping}>
|
|
||||||
<td><code>{adif}</code></td>
|
|
||||||
<td><code>{mapped}</code></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
id="adif-upload-form"
|
|
||||||
phx-submit="upload_adif"
|
|
||||||
phx-change="validate_adif"
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div class="fieldset mb-2">
|
|
||||||
<label>
|
|
||||||
<span class="label mb-1">ADIF File</span>
|
|
||||||
<.live_file_input upload={@uploads.adif} class="file-input file-input-bordered w-full" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :for={entry <- @uploads.adif.entries} class="space-y-1">
|
|
||||||
<div class="flex items-center justify-between text-xs tabular-nums">
|
|
||||||
<span class="truncate pr-2">{entry.client_name}</span>
|
|
||||||
<span class="flex items-center gap-2 shrink-0">
|
|
||||||
<span :if={uploading?(entry)}>{entry.progress}%</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
phx-click="cancel_adif_upload"
|
|
||||||
phx-value-ref={entry.ref}
|
|
||||||
aria-label="Cancel upload"
|
|
||||||
>
|
|
||||||
<.icon name="hero-x-mark" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
:if={uploading?(entry)}
|
|
||||||
class="progress progress-primary w-full"
|
|
||||||
value={entry.progress}
|
|
||||||
max="100"
|
|
||||||
></progress>
|
|
||||||
<div :for={err <- upload_errors(@uploads.adif, entry)} class="text-xs text-error">
|
|
||||||
{error_to_string(err)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :for={err <- upload_errors(@uploads.adif)} class="text-xs text-error">
|
|
||||||
{error_to_string(err)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if @current_user do %>
|
|
||||||
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer mt-2">
|
|
||||||
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
|
|
||||||
<span class="text-sm">Private — only visible to me and administrators</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
|
||||||
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp csv_preview(assigns) do
|
|
||||||
assigns =
|
|
||||||
assign(assigns,
|
|
||||||
valid_count: length(assigns.preview.valid),
|
|
||||||
invalid_count: length(assigns.preview.invalid),
|
|
||||||
duplicate_count: length(assigns.preview.duplicates),
|
|
||||||
refinement_count: length(assigns.preview.refinements || []),
|
|
||||||
valid_sample: Enum.take(assigns.preview.valid, 20),
|
|
||||||
refinement_sample: Enum.take(assigns.preview.refinements || [], 50)
|
|
||||||
)
|
|
||||||
|
|
||||||
~H"""
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-bold mb-2">Review before submitting</h2>
|
|
||||||
<p class="text-sm opacity-70">
|
|
||||||
Processed {@preview.total_rows} {if @preview.total_rows == 1, do: "row", else: "rows"}.
|
|
||||||
Nothing has been inserted yet — confirm below to import.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
||||||
<.summary_card
|
|
||||||
tone="success"
|
|
||||||
label="Valid"
|
|
||||||
value={@valid_count}
|
|
||||||
hint="Will be inserted"
|
|
||||||
/>
|
|
||||||
<.summary_card
|
|
||||||
tone="info"
|
|
||||||
label="Refinements"
|
|
||||||
value={@refinement_count}
|
|
||||||
hint="Will update existing contacts"
|
|
||||||
/>
|
|
||||||
<.summary_card
|
|
||||||
tone="warning"
|
|
||||||
label="Duplicates"
|
|
||||||
value={@duplicate_count}
|
|
||||||
hint="Match existing or earlier rows"
|
|
||||||
/>
|
|
||||||
<.summary_card
|
|
||||||
tone="error"
|
|
||||||
label="Invalid"
|
|
||||||
value={@invalid_count}
|
|
||||||
hint="Skipped — see errors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :if={@invalid_count > 0}>
|
|
||||||
<h3 class="font-semibold mb-2">Invalid rows ({@invalid_count})</h3>
|
|
||||||
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
||||||
<table class="table table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-24">Row</th>
|
|
||||||
<th>Errors</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr :for={row <- Enum.take(@preview.invalid, 100)}>
|
|
||||||
<td class="font-mono">Row {row.row_num}</td>
|
|
||||||
<td>
|
|
||||||
<div :for={msg <- row.messages}>{msg}</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p :if={@invalid_count > 100} class="text-xs opacity-60 mt-1">
|
|
||||||
Showing first 100 of {@invalid_count} invalid rows.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :if={@refinement_count > 0}>
|
|
||||||
<h3 class="font-semibold mb-2">Refinements ({@refinement_count})</h3>
|
|
||||||
<p class="text-xs opacity-60 mb-2">
|
|
||||||
These rows match an existing contact but add more precise data.
|
|
||||||
Confirming will update the existing contact in place.
|
|
||||||
</p>
|
|
||||||
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
||||||
<table class="table table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-20">Row</th>
|
|
||||||
<th>Station 1</th>
|
|
||||||
<th>Station 2</th>
|
|
||||||
<th>Band</th>
|
|
||||||
<th>Changes</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr :for={row <- @refinement_sample}>
|
|
||||||
<td class="font-mono">Row {row.row_num}</td>
|
|
||||||
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
||||||
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
||||||
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
||||||
<td class="text-xs">
|
|
||||||
<div :for={{field, value} <- Enum.sort(Map.to_list(row.changes))}>
|
|
||||||
<span class="opacity-60">{field}:</span>
|
|
||||||
<code class="text-xs">{value}</code>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p :if={@refinement_count > length(@refinement_sample)} class="text-xs opacity-60 mt-1">
|
|
||||||
Showing first {length(@refinement_sample)} of {@refinement_count} refinements.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :if={@duplicate_count > 0}>
|
|
||||||
<h3 class="font-semibold mb-2">Duplicates ({@duplicate_count})</h3>
|
|
||||||
<p class="text-xs opacity-60 mb-2">
|
|
||||||
Same two callsigns at the same grids on the same band within an hour.
|
|
||||||
</p>
|
|
||||||
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
||||||
<table class="table table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-20">Row</th>
|
|
||||||
<th>Station 1</th>
|
|
||||||
<th>Station 2</th>
|
|
||||||
<th>Band</th>
|
|
||||||
<th>Timestamp</th>
|
|
||||||
<th>Source</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr :for={row <- Enum.take(@preview.duplicates, 100)}>
|
|
||||||
<td class="font-mono">Row {row.row_num}</td>
|
|
||||||
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
||||||
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
||||||
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
||||||
<td class="font-mono text-xs">
|
|
||||||
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
||||||
</td>
|
|
||||||
<td class="text-xs opacity-70">{duplicate_source(row.reason)}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p :if={@duplicate_count > 100} class="text-xs opacity-60 mt-1">
|
|
||||||
Showing first 100 of {@duplicate_count} duplicates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :if={@valid_count > 0}>
|
|
||||||
<h3 class="font-semibold mb-2">Valid rows ready to import ({@valid_count})</h3>
|
|
||||||
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
||||||
<table class="table table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-20">Row</th>
|
|
||||||
<th>Station 1</th>
|
|
||||||
<th>Station 2</th>
|
|
||||||
<th>Band</th>
|
|
||||||
<th>Mode</th>
|
|
||||||
<th>Timestamp</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr :for={row <- @valid_sample}>
|
|
||||||
<td class="font-mono">Row {row.row_num}</td>
|
|
||||||
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
||||||
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
||||||
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
||||||
<td>{row.attrs["mode"]}</td>
|
|
||||||
<td class="font-mono text-xs">
|
|
||||||
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p :if={@valid_count > length(@valid_sample)} class="text-xs opacity-60 mt-1">
|
|
||||||
Showing first {length(@valid_sample)} of {@valid_count} valid rows.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
|
|
||||||
<button
|
|
||||||
:if={@valid_count > 0 or @refinement_count > 0}
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-lg"
|
|
||||||
phx-click="confirm_csv"
|
|
||||||
phx-disable-with="Importing..."
|
|
||||||
data-confirm={confirm_prompt(@valid_count, @refinement_count)}
|
|
||||||
>
|
|
||||||
<.icon name="hero-check" class="w-5 h-5" />
|
|
||||||
{confirm_button_label(@valid_count, @refinement_count)}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost" phx-click="cancel_csv">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
attr :tone, :string, required: true
|
|
||||||
attr :label, :string, required: true
|
|
||||||
attr :value, :integer, required: true
|
|
||||||
attr :hint, :string, required: true
|
|
||||||
|
|
||||||
defp summary_card(assigns) do
|
|
||||||
~H"""
|
|
||||||
<div class={[
|
|
||||||
"rounded-box border p-4",
|
|
||||||
case @tone do
|
|
||||||
"success" -> "border-success/30 bg-success/10"
|
|
||||||
"warning" -> "border-warning/30 bg-warning/10"
|
|
||||||
"error" -> "border-error/30 bg-error/10"
|
|
||||||
"info" -> "border-info/30 bg-info/10"
|
|
||||||
_ -> "border-base-300 bg-base-200"
|
|
||||||
end
|
|
||||||
]}>
|
|
||||||
<div class="text-xs uppercase tracking-wider opacity-70">{@label}</div>
|
|
||||||
<div class="text-3xl font-bold tabular-nums">{@value}</div>
|
|
||||||
<div class="text-xs opacity-60">{@hint}</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp duplicate_source(:existing_contact), do: "Already in database"
|
|
||||||
defp duplicate_source(:earlier_in_upload), do: "Earlier row in this upload"
|
|
||||||
defp duplicate_source(_), do: "Duplicate"
|
|
||||||
|
|
||||||
defp pluralize(1, word), do: word
|
|
||||||
defp pluralize(_, word), do: word <> "s"
|
|
||||||
|
|
||||||
# An upload entry is actively streaming when its progress is partway
|
|
||||||
# between 0 and 100. With `auto_upload: false` entries sit at
|
|
||||||
# `progress: 0` after the user picks a file and only start streaming
|
|
||||||
# when the form is submitted, so gating the progress bar and percent
|
|
||||||
# text on the 0 < progress < 100 window keeps them hidden until the
|
|
||||||
# upload is actually in flight.
|
|
||||||
defp uploading?(%{progress: progress}), do: progress > 0 and progress < 100
|
|
||||||
defp uploading?(_), do: false
|
|
||||||
|
|
||||||
defp confirm_button_label(valid, 0) do
|
|
||||||
"Looks good — submit #{valid} #{pluralize(valid, "contact")}"
|
|
||||||
end
|
|
||||||
|
|
||||||
defp confirm_button_label(0, refined) do
|
|
||||||
"Looks good — refine #{refined} existing #{pluralize(refined, "contact")}"
|
|
||||||
end
|
|
||||||
|
|
||||||
defp confirm_button_label(valid, refined) do
|
|
||||||
"Looks good — submit #{valid} and refine #{refined}"
|
|
||||||
end
|
|
||||||
|
|
||||||
defp confirm_prompt(valid, 0), do: "Import #{valid} contacts?"
|
|
||||||
defp confirm_prompt(0, refined), do: "Refine #{refined} existing contacts?"
|
|
||||||
|
|
||||||
defp confirm_prompt(valid, refined) do
|
|
||||||
"Import #{valid} contacts and refine #{refined} existing?"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
131
lib/microwaveprop_web/live/submit_live/adif_upload_component.ex
Normal file
131
lib/microwaveprop_web/live/submit_live/adif_upload_component.ex
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
defmodule MicrowavepropWeb.SubmitLive.AdifUploadComponent do
|
||||||
|
@moduledoc false
|
||||||
|
use MicrowavepropWeb, :html
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.AdifImport
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec adif_upload_form(map()) :: Phoenix.LiveView.Rendered.t()
|
||||||
|
def adif_upload_form(assigns) do
|
||||||
|
assigns = assign(assigns, mode_mapping: AdifImport.mode_mapping_reference())
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="alert text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="mb-2">
|
||||||
|
Upload an ADIF (.adi / .adif) file exported from your logging program.
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-outside pl-5 mt-2 space-y-1 text-base-content/60">
|
||||||
|
<li>
|
||||||
|
Required fields: <code>CALL</code>, <code>STATION_CALLSIGN</code>
|
||||||
|
(or <code>OPERATOR</code>), <code>GRIDSQUARE</code>, <code>MY_GRIDSQUARE</code>, <code>QSO_DATE</code>, <code>TIME_ON</code>,
|
||||||
|
and <code>FREQ</code>
|
||||||
|
or <code>BAND</code>
|
||||||
|
</li>
|
||||||
|
<li>Contacts on amateur bands from 50 MHz and up will be imported</li>
|
||||||
|
<li>HF contacts (below 50 MHz) are silently skipped</li>
|
||||||
|
<li>Frequencies are fuzzy-matched to the nearest amateur band</li>
|
||||||
|
<li>
|
||||||
|
Operator commentary from <code>NOTES</code>
|
||||||
|
is carried over (falling back to <code>COMMENT</code>)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert text-sm">
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="font-semibold mb-2">Mode handling</p>
|
||||||
|
<p class="mb-2 text-base-content/70">
|
||||||
|
ADIF modes are normalized to the six modes we track. SUBMODE takes
|
||||||
|
precedence when present (e.g. <code>MODE=MFSK, SUBMODE=FT8</code> imports as FT8).
|
||||||
|
</p>
|
||||||
|
<table class="table table-xs mt-2">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ADIF value</th>
|
||||||
|
<th>Stored as</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :for={{adif, mapped} <- @mode_mapping}>
|
||||||
|
<td><code>{adif}</code></td>
|
||||||
|
<td><code>{mapped}</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="adif-upload-form"
|
||||||
|
phx-submit="upload_adif"
|
||||||
|
phx-change="validate_adif"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label>
|
||||||
|
<span class="label mb-1">ADIF File</span>
|
||||||
|
<.live_file_input upload={@uploads.adif} class="file-input file-input-bordered w-full" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :for={entry <- @uploads.adif.entries} class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between text-xs tabular-nums">
|
||||||
|
<span class="truncate pr-2">{entry.client_name}</span>
|
||||||
|
<span class="flex items-center gap-2 shrink-0">
|
||||||
|
<span :if={uploading?(entry)}>{entry.progress}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
phx-click="cancel_adif_upload"
|
||||||
|
phx-value-ref={entry.ref}
|
||||||
|
aria-label="Cancel upload"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
:if={uploading?(entry)}
|
||||||
|
class="progress progress-primary w-full"
|
||||||
|
value={entry.progress}
|
||||||
|
max="100"
|
||||||
|
></progress>
|
||||||
|
<div :for={err <- upload_errors(@uploads.adif, entry)} class="text-xs text-error">
|
||||||
|
{error_to_string(err)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :for={err <- upload_errors(@uploads.adif)} class="text-xs text-error">
|
||||||
|
{error_to_string(err)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @current_user do %>
|
||||||
|
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer mt-2">
|
||||||
|
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
|
||||||
|
<span class="text-sm">Private — only visible to me and administrators</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
||||||
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp error_to_string(:too_large), do: "File is too large"
|
||||||
|
defp error_to_string(:too_many_files), do: "Only one file allowed"
|
||||||
|
defp error_to_string(:not_accepted), do: "File type not accepted"
|
||||||
|
defp error_to_string(other), do: to_string(other)
|
||||||
|
|
||||||
|
defp uploading?(%{progress: progress}), do: progress > 0 and progress < 100
|
||||||
|
defp uploading?(_), do: false
|
||||||
|
end
|
||||||
112
lib/microwaveprop_web/live/submit_live/csv_upload_component.ex
Normal file
112
lib/microwaveprop_web/live/submit_live/csv_upload_component.ex
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
defmodule MicrowavepropWeb.SubmitLive.CsvUploadComponent do
|
||||||
|
@moduledoc false
|
||||||
|
use MicrowavepropWeb, :html
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec csv_upload_form(map()) :: Phoenix.LiveView.Rendered.t()
|
||||||
|
def csv_upload_form(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="alert text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="mb-2">Upload a CSV file with multiple contacts. Columns:</p>
|
||||||
|
<code class="text-xs">
|
||||||
|
station1, station2, grid1, grid2, band, mode, qso_timestamp, notes
|
||||||
|
</code>
|
||||||
|
<ul class="list-disc list-outside pl-5 mt-2 space-y-1 text-base-content/60">
|
||||||
|
<li>
|
||||||
|
Timestamps in most formats accepted (e.g. <code>2024-06-15T14:30:00Z</code>, <code>6/15/2024 2:30 PM</code>, <code>2024-06-15 14:30</code>). All times assumed UTC.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
|
||||||
|
</li>
|
||||||
|
<li>Band in MHz (e.g. 10000, 24000)</li>
|
||||||
|
<li>
|
||||||
|
Mode is <strong>optional</strong> — omit the <code>mode</code> column entirely
|
||||||
|
(6 columns total) or leave its value blank.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Notes is <strong>optional</strong> — free-form operator commentary up to
|
||||||
|
2000 characters. Leave the cell blank to store NULL.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-2">
|
||||||
|
<a href="/downloads/sample_contacts.csv" download class="link link-primary">
|
||||||
|
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="csv-upload-form" phx-submit="upload_csv" phx-change="validate_csv" class="space-y-4">
|
||||||
|
<div class="fieldset mb-2">
|
||||||
|
<label>
|
||||||
|
<span class="label mb-1">CSV File</span>
|
||||||
|
<.live_file_input upload={@uploads.csv} class="file-input file-input-bordered w-full" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :for={entry <- @uploads.csv.entries} class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between text-xs tabular-nums">
|
||||||
|
<span class="truncate pr-2">{entry.client_name}</span>
|
||||||
|
<span class="flex items-center gap-2 shrink-0">
|
||||||
|
<span :if={uploading?(entry)}>{entry.progress}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
phx-click="cancel_csv_upload"
|
||||||
|
phx-value-ref={entry.ref}
|
||||||
|
aria-label="Cancel upload"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
:if={uploading?(entry)}
|
||||||
|
class="progress progress-primary w-full"
|
||||||
|
value={entry.progress}
|
||||||
|
max="100"
|
||||||
|
></progress>
|
||||||
|
<div :for={err <- upload_errors(@uploads.csv, entry)} class="text-xs text-error">
|
||||||
|
{error_to_string(err)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :for={err <- upload_errors(@uploads.csv)} class="text-xs text-error">
|
||||||
|
{error_to_string(err)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if @current_user do %>
|
||||||
|
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer mt-2">
|
||||||
|
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
|
||||||
|
<span class="text-sm">Private — only visible to me and administrators</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
||||||
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp error_to_string(:too_large), do: "File is too large"
|
||||||
|
defp error_to_string(:too_many_files), do: "Only one file allowed"
|
||||||
|
defp error_to_string(:not_accepted), do: "File type not accepted"
|
||||||
|
defp error_to_string(other), do: to_string(other)
|
||||||
|
|
||||||
|
# An upload entry is actively streaming when its progress is partway
|
||||||
|
# between 0 and 100. With `auto_upload: false` entries sit at
|
||||||
|
# `progress: 0` after the user picks a file and only start streaming
|
||||||
|
# when the form is submitted, so gating the progress bar and percent
|
||||||
|
# text on the 0 < progress < 100 window keeps them hidden until the
|
||||||
|
# upload is actually in flight.
|
||||||
|
defp uploading?(%{progress: progress}), do: progress > 0 and progress < 100
|
||||||
|
defp uploading?(_), do: false
|
||||||
|
end
|
||||||
260
lib/microwaveprop_web/live/submit_live/preview_component.ex
Normal file
260
lib/microwaveprop_web/live/submit_live/preview_component.ex
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
defmodule MicrowavepropWeb.SubmitLive.PreviewComponent do
|
||||||
|
@moduledoc false
|
||||||
|
use MicrowavepropWeb, :html
|
||||||
|
|
||||||
|
alias Phoenix.LiveView.Rendered
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec csv_preview(map()) :: Rendered.t()
|
||||||
|
def csv_preview(assigns) do
|
||||||
|
assigns =
|
||||||
|
assign(assigns,
|
||||||
|
valid_count: length(assigns.preview.valid),
|
||||||
|
invalid_count: length(assigns.preview.invalid),
|
||||||
|
duplicate_count: length(assigns.preview.duplicates),
|
||||||
|
refinement_count: length(assigns.preview.refinements || []),
|
||||||
|
valid_sample: Enum.take(assigns.preview.valid, 20),
|
||||||
|
refinement_sample: Enum.take(assigns.preview.refinements || [], 50)
|
||||||
|
)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold mb-2">Review before submitting</h2>
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
Processed {@preview.total_rows} {if @preview.total_rows == 1, do: "row", else: "rows"}.
|
||||||
|
Nothing has been inserted yet — confirm below to import.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<.summary_card
|
||||||
|
tone="success"
|
||||||
|
label="Valid"
|
||||||
|
value={@valid_count}
|
||||||
|
hint="Will be inserted"
|
||||||
|
/>
|
||||||
|
<.summary_card
|
||||||
|
tone="info"
|
||||||
|
label="Refinements"
|
||||||
|
value={@refinement_count}
|
||||||
|
hint="Will update existing contacts"
|
||||||
|
/>
|
||||||
|
<.summary_card
|
||||||
|
tone="warning"
|
||||||
|
label="Duplicates"
|
||||||
|
value={@duplicate_count}
|
||||||
|
hint="Match existing or earlier rows"
|
||||||
|
/>
|
||||||
|
<.summary_card
|
||||||
|
tone="error"
|
||||||
|
label="Invalid"
|
||||||
|
value={@invalid_count}
|
||||||
|
hint="Skipped — see errors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@invalid_count > 0}>
|
||||||
|
<h3 class="font-semibold mb-2">Invalid rows ({@invalid_count})</h3>
|
||||||
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-24">Row</th>
|
||||||
|
<th>Errors</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :for={row <- Enum.take(@preview.invalid, 100)}>
|
||||||
|
<td class="font-mono">Row {row.row_num}</td>
|
||||||
|
<td>
|
||||||
|
<div :for={msg <- row.messages}>{msg}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p :if={@invalid_count > 100} class="text-xs opacity-60 mt-1">
|
||||||
|
Showing first 100 of {@invalid_count} invalid rows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@refinement_count > 0}>
|
||||||
|
<h3 class="font-semibold mb-2">Refinements ({@refinement_count})</h3>
|
||||||
|
<p class="text-xs opacity-60 mb-2">
|
||||||
|
These rows match an existing contact but add more precise data.
|
||||||
|
Confirming will update the existing contact in place.
|
||||||
|
</p>
|
||||||
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-20">Row</th>
|
||||||
|
<th>Station 1</th>
|
||||||
|
<th>Station 2</th>
|
||||||
|
<th>Band</th>
|
||||||
|
<th>Changes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :for={row <- @refinement_sample}>
|
||||||
|
<td class="font-mono">Row {row.row_num}</td>
|
||||||
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
||||||
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
||||||
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
<div :for={{field, value} <- Enum.sort(Map.to_list(row.changes))}>
|
||||||
|
<span class="opacity-60">{field}:</span>
|
||||||
|
<code class="text-xs">{value}</code>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p :if={@refinement_count > length(@refinement_sample)} class="text-xs opacity-60 mt-1">
|
||||||
|
Showing first {length(@refinement_sample)} of {@refinement_count} refinements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@duplicate_count > 0}>
|
||||||
|
<h3 class="font-semibold mb-2">Duplicates ({@duplicate_count})</h3>
|
||||||
|
<p class="text-xs opacity-60 mb-2">
|
||||||
|
Same two callsigns at the same grids on the same band within an hour.
|
||||||
|
</p>
|
||||||
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-20">Row</th>
|
||||||
|
<th>Station 1</th>
|
||||||
|
<th>Station 2</th>
|
||||||
|
<th>Band</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :for={row <- Enum.take(@preview.duplicates, 100)}>
|
||||||
|
<td class="font-mono">Row {row.row_num}</td>
|
||||||
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
||||||
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
||||||
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
||||||
|
<td class="font-mono text-xs">
|
||||||
|
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs opacity-70">{duplicate_source(row.reason)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p :if={@duplicate_count > 100} class="text-xs opacity-60 mt-1">
|
||||||
|
Showing first 100 of {@duplicate_count} duplicates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@valid_count > 0}>
|
||||||
|
<h3 class="font-semibold mb-2">Valid rows ready to import ({@valid_count})</h3>
|
||||||
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-20">Row</th>
|
||||||
|
<th>Station 1</th>
|
||||||
|
<th>Station 2</th>
|
||||||
|
<th>Band</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :for={row <- @valid_sample}>
|
||||||
|
<td class="font-mono">Row {row.row_num}</td>
|
||||||
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
||||||
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
||||||
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
||||||
|
<td>{row.attrs["mode"]}</td>
|
||||||
|
<td class="font-mono text-xs">
|
||||||
|
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p :if={@valid_count > length(@valid_sample)} class="text-xs opacity-60 mt-1">
|
||||||
|
Showing first {length(@valid_sample)} of {@valid_count} valid rows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
|
||||||
|
<button
|
||||||
|
:if={@valid_count > 0 or @refinement_count > 0}
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
phx-click="confirm_csv"
|
||||||
|
phx-disable-with="Importing..."
|
||||||
|
data-confirm={confirm_prompt(@valid_count, @refinement_count)}
|
||||||
|
>
|
||||||
|
<.icon name="hero-check" class="w-5 h-5" />
|
||||||
|
{confirm_button_label(@valid_count, @refinement_count)}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost" phx-click="cancel_csv">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :tone, :string, required: true
|
||||||
|
attr :label, :string, required: true
|
||||||
|
attr :value, :integer, required: true
|
||||||
|
attr :hint, :string, required: true
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@spec summary_card(map()) :: Rendered.t()
|
||||||
|
def summary_card(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class={[
|
||||||
|
"rounded-box border p-4",
|
||||||
|
case @tone do
|
||||||
|
"success" -> "border-success/30 bg-success/10"
|
||||||
|
"warning" -> "border-warning/30 bg-warning/10"
|
||||||
|
"error" -> "border-error/30 bg-error/10"
|
||||||
|
"info" -> "border-info/30 bg-info/10"
|
||||||
|
_ -> "border-base-300 bg-base-200"
|
||||||
|
end
|
||||||
|
]}>
|
||||||
|
<div class="text-xs uppercase tracking-wider opacity-70">{@label}</div>
|
||||||
|
<div class="text-3xl font-bold tabular-nums">{@value}</div>
|
||||||
|
<div class="text-xs opacity-60">{@hint}</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp duplicate_source(:existing_contact), do: "Already in database"
|
||||||
|
defp duplicate_source(:earlier_in_upload), do: "Earlier row in this upload"
|
||||||
|
defp duplicate_source(_), do: "Duplicate"
|
||||||
|
|
||||||
|
defp pluralize(1, word), do: word
|
||||||
|
defp pluralize(_, word), do: word <> "s"
|
||||||
|
|
||||||
|
defp confirm_button_label(valid, 0) do
|
||||||
|
"Looks good — submit #{valid} #{pluralize(valid, "contact")}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp confirm_button_label(0, refined) do
|
||||||
|
"Looks good — refine #{refined} existing #{pluralize(refined, "contact")}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp confirm_button_label(valid, refined) do
|
||||||
|
"Looks good — submit #{valid} and refine #{refined}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp confirm_prompt(valid, 0), do: "Import #{valid} contacts?"
|
||||||
|
defp confirm_prompt(0, refined), do: "Refine #{refined} existing contacts?"
|
||||||
|
|
||||||
|
defp confirm_prompt(valid, refined) do
|
||||||
|
"Import #{valid} contacts and refine #{refined} existing?"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -7,6 +7,8 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
|
||||||
"""
|
"""
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
alias Microwaveprop.Weather.MapLayers
|
alias Microwaveprop.Weather.MapLayers
|
||||||
alias MicrowavepropWeb.WeatherMapComponent
|
alias MicrowavepropWeb.WeatherMapComponent
|
||||||
|
|
@ -24,11 +26,8 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
_ =
|
subscribe(socket, "weather:updated")
|
||||||
if connected?(socket) do
|
subscribe(socket, "propagation:pipeline")
|
||||||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
|
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
|
||||||
end
|
|
||||||
|
|
||||||
valid_times = recent_valid_times(Weather.available_hrdps_valid_times())
|
valid_times = recent_valid_times(Weather.available_hrdps_valid_times())
|
||||||
initial_vt = pick_initial_valid_time(valid_times)
|
initial_vt = pick_initial_valid_time(valid_times)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
@moduledoc "`/weather` — HRRR-derived forecast fields (temp, Td, wind, refractivity) on a map."
|
@moduledoc "`/weather` — HRRR-derived forecast fields (temp, Td, wind, refractivity) on a map."
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
alias Microwaveprop.Weather.MapLayers
|
alias Microwaveprop.Weather.MapLayers
|
||||||
alias MicrowavepropWeb.WeatherMapComponent
|
alias MicrowavepropWeb.WeatherMapComponent
|
||||||
|
|
@ -26,11 +28,8 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(params, _session, socket) do
|
def mount(params, _session, socket) do
|
||||||
_ =
|
subscribe(socket, "weather:updated")
|
||||||
if connected?(socket) do
|
subscribe(socket, "propagation:pipeline")
|
||||||
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
|
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Default the timeline cursor to the valid_time closest to "now" so
|
# Default the timeline cursor to the valid_time closest to "now" so
|
||||||
# users land on current conditions, not a +11h forecast hour (which
|
# users land on current conditions, not a +11h forecast hour (which
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ defmodule MicrowavepropWeb.LiveHelpers do
|
||||||
view-specific logic belongs in its own LiveView.
|
view-specific logic belongs in its own LiveView.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ── Parse helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
alias Phoenix.LiveView.Socket
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Parses a value into a float, returning `default` for nil, empty, or
|
Parses a value into a float, returning `default` for nil, empty, or
|
||||||
unparseable input. Accepts strings, numbers, or any value with a
|
unparseable input. Accepts strings, numbers, or any value with a
|
||||||
|
|
@ -34,4 +38,46 @@ defmodule MicrowavepropWeb.LiveHelpers do
|
||||||
:error -> default
|
:error -> default
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── User helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Extracts the current user from a LiveView socket or assigns map.
|
||||||
|
Returns the `%User{}` struct or `nil` if not authenticated.
|
||||||
|
"""
|
||||||
|
@spec current_user(Socket.t() | map()) :: Ecto.Schema.t() | nil
|
||||||
|
def current_user(%Socket{assigns: assigns}) do
|
||||||
|
current_user(assigns)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec current_user(map()) :: Ecto.Schema.t() | nil
|
||||||
|
def current_user(%{current_scope: %{user: %_{} = user}}), do: user
|
||||||
|
def current_user(%{user: %_{} = user}), do: user
|
||||||
|
def current_user(_), do: nil
|
||||||
|
|
||||||
|
# ── PubSub helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Subscribes the socket to a Phoenix.PubSub topic, but only when the
|
||||||
|
socket is connected (WebSocket). Safe to call unconditionally in mount/3.
|
||||||
|
"""
|
||||||
|
@spec subscribe(Socket.t(), String.t()) :: :ok
|
||||||
|
def subscribe(%Socket{} = socket, topic) when is_binary(topic) do
|
||||||
|
if Phoenix.LiveView.connected?(socket) do
|
||||||
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, topic)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── URL helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Merges URL params with defaults for handle_params/3. Returns a map of
|
||||||
|
merged values suitable for assigning to the socket.
|
||||||
|
"""
|
||||||
|
@spec assign_url_params(map(), map()) :: map()
|
||||||
|
def assign_url_params(params, defaults) when is_map(params) and is_map(defaults) do
|
||||||
|
Map.merge(defaults, Map.take(params, Map.keys(defaults)))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,13 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
|
||||||
test "rejects invalid grid1" do
|
test "rejects invalid grid1" do
|
||||||
attrs = Map.put(@valid_attrs, :grid1, "ZZ99")
|
attrs = Map.put(@valid_attrs, :grid1, "ZZ99")
|
||||||
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid1
|
assert "is not a valid Maidenhead grid" in errors_on(changeset).grid1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects invalid grid2" do
|
test "rejects invalid grid2" do
|
||||||
attrs = Map.put(@valid_attrs, :grid2, "ZZ99")
|
attrs = Map.put(@valid_attrs, :grid2, "ZZ99")
|
||||||
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid2
|
assert "is not a valid Maidenhead grid" in errors_on(changeset).grid2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts valid 6-char grids" do
|
test "accepts valid 6-char grids" do
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ defmodule MicrowavepropWeb.UserSettingsControllerTest do
|
||||||
|
|
||||||
response = html_response(conn, 200)
|
response = html_response(conn, 200)
|
||||||
assert response =~ "Home QTH"
|
assert response =~ "Home QTH"
|
||||||
assert response =~ "must be a 4 or 6 character"
|
assert response =~ "is not a valid Maidenhead"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue