simplify: DRY up shared changesets, context helpers, LiveView helpers, and structural extraction
Some checks failed
Build base image / Build and push base image (push) Successful in 12s
Build and Push / Build CI test image (push) Successful in 14s
Build and Push / Build and Push Docker Image (push) Failing after 14m7s

- 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:
Graham McIntire 2026-08-06 18:06:50 -05:00
parent 377ef70f5c
commit b4b8d4ec47
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
37 changed files with 1477 additions and 1425 deletions

View file

@ -9,6 +9,7 @@ defmodule Microwaveprop.Accounts do
alias Microwaveprop.Accounts.UserApiToken
alias Microwaveprop.Accounts.UserNotifier
alias Microwaveprop.Accounts.UserToken
alias Microwaveprop.ContextHelpers
alias Microwaveprop.Repo
## Database getters
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
_ =
if home_qth_lookup_enabled?() do
worker = UserHomeQthLookupWorker
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
_ = %{user_id: id} |> worker.new() |> Oban.insert()
end
ContextHelpers.safe_enqueue(UserHomeQthLookupWorker, %{user_id: id})
end
{:ok, user}
@ -516,22 +513,24 @@ defmodule Microwaveprop.Accounts do
@spec revoke_api_token(User.t(), Ecto.UUID.t()) ::
{:ok, UserApiToken.t()} | {:error, :not_found}
def revoke_api_token(%User{id: user_id}, token_id) do
case Repo.get_by(UserApiToken, id: token_id, user_id: user_id) do
nil ->
case Ecto.UUID.cast(token_id) do
{: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}
%UserApiToken{revoked_at: nil} = token ->
token
|> Ecto.Changeset.change(revoked_at: DateTime.utc_now(:second))
|> Repo.update()
%UserApiToken{} = token ->
{:ok, token}
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
## Token helper

View file

@ -4,7 +4,7 @@ defmodule Microwaveprop.Accounts.User do
import Ecto.Changeset
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
@admin_email "graham@mcintire.me"
@ -227,10 +227,10 @@ defmodule Microwaveprop.Accounts.User do
user
|> cast(attrs, [:home_grid, :home_lat, :home_lon, :home_elevation_m])
|> normalize_home_grid()
|> validate_home_grid_format()
|> derive_home_latlon_from_grid()
|> derive_home_grid_from_latlon()
|> validate_home_latlon_range()
|> MCH.validate_grid(:home_grid, blank_error: false)
|> MCH.derive_latlon(:home_grid, :home_lat, :home_lon)
|> MCH.derive_grid(:home_grid, :home_lat, :home_lon, 10)
|> MCH.validate_latlon(:home_lat, :home_lon)
|> require_home_qth_pair()
end
@ -243,86 +243,10 @@ defmodule Microwaveprop.Accounts.User do
put_change(changeset, :home_grid, nil)
grid when is_binary(grid) ->
put_change(changeset, :home_grid, normalize_grid(grid))
put_change(changeset, :home_grid, MCH.normalize_grid(grid))
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
if home_qth_satisfied?(changeset),
do: changeset,

View file

@ -48,32 +48,25 @@ defmodule Microwaveprop.BeaconMeasurements do
defp lookup_beacon(attrs) do
attrs = Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
case Map.get(attrs, "beacon_id") do
id when is_binary(id) and byte_size(id) > 0 ->
# Only let monitors report for approved on-the-air beacons. The
# client drops 404s without retry, so an admin un-approving a
# beacon cleanly stops the monitor pointing at it.
query =
from b in Microwaveprop.Beacons.Beacon,
where: b.id == ^id and b.approved == true and b.on_the_air == true,
select: b.id
with id when is_binary(id) and byte_size(id) > 0 <- Map.get(attrs, "beacon_id"),
{:ok, uuid} <- Ecto.UUID.cast(id) do
# Only let monitors report for approved on-the-air beacons. The
# client drops 404s without retry, so an admin un-approving a
# beacon cleanly stops the monitor pointing at it.
query =
from b in Microwaveprop.Beacons.Beacon,
where: b.id == ^uuid and b.approved == true and b.on_the_air == true,
select: b.id
case safe_one(query) do
nil -> :not_found
uuid -> {:ok, uuid}
end
_ ->
:not_found
case Repo.one(query) do
nil -> :not_found
uuid -> {:ok, uuid}
end
else
_ -> :not_found
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
# we don't drop a value because some intermediate handed us atom keys.
defp normalize_attrs(attrs) do

View file

@ -8,7 +8,6 @@ defmodule Microwaveprop.BeaconMonitors do
import Ecto.Query
alias Ecto.Query.CastError
alias Microwaveprop.Accounts.User
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Repo
@ -143,16 +142,20 @@ defmodule Microwaveprop.BeaconMonitors do
@spec delete_monitor(User.t(), Ecto.UUID.t()) ::
{:ok, BeaconMonitor.t()} | {:error, :not_found}
def delete_monitor(%User{id: user_id}, monitor_id) do
query =
from m in BeaconMonitor,
where: m.id == ^monitor_id and m.user_id == ^user_id
case Ecto.UUID.cast(monitor_id) do
{:ok, uuid} ->
query =
from m in BeaconMonitor,
where: m.id == ^uuid and m.user_id == ^user_id
case Repo.one(query) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
case Repo.one(query) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
:error ->
{:error, :not_found}
end
rescue
CastError -> {:error, :not_found}
end
@doc """
@ -160,12 +163,16 @@ defmodule Microwaveprop.BeaconMonitors do
"""
@spec delete_monitor!(Ecto.UUID.t()) :: {:ok, BeaconMonitor.t()} | {:error, :not_found}
def delete_monitor!(monitor_id) do
case Repo.get(BeaconMonitor, monitor_id) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
case Ecto.UUID.cast(monitor_id) do
{:ok, uuid} ->
case Repo.get(BeaconMonitor, uuid) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
:error ->
{:error, :not_found}
end
rescue
CastError -> {:error, :not_found}
end
# ── Auth / heartbeat ─────────────────────────────────────────────

View file

@ -14,6 +14,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
alias Microwaveprop.Accounts.User
alias Microwaveprop.Beacons.Beacon
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -90,8 +91,7 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|> validate_length(:antenna_type, max: 100)
|> validate_length(:callsign, max: 20)
|> 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)
|> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|> MCH.validate_latlon(:lat, :lon)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:assigned_by_id)
end

View file

@ -104,9 +104,12 @@ defmodule Microwaveprop.Beacons do
end
defp fetch_beacon(id) do
Beacon |> Repo.get(id) |> Repo.preload(:user)
rescue
Ecto.Query.CastError -> nil
with {:ok, uuid} <- Ecto.UUID.cast(id),
%Beacon{} = beacon <- Repo.get(Beacon, uuid) do
Repo.preload(beacon, :user)
else
_ -> nil
end
end
defp can_view?(%Beacon{approved: true}, _viewer), do: true

View file

@ -9,7 +9,7 @@ defmodule Microwaveprop.Beacons.Beacon do
import Ecto.Changeset
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
@keying_entries [
{"on_off", "On/Off"},
@ -180,12 +180,12 @@ defmodule Microwaveprop.Beacons.Beacon do
:beamwidth_deg,
: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(:lon, &round_coord/1)
|> update_change(:height_ft, &round_int/1)
|> maybe_fill_latlon()
|> maybe_fill_grid()
|> MCH.derive_latlon(:grid, :lat, :lon)
|> MCH.derive_grid(:grid, :lat, :lon)
|> normalize_bearing_change()
|> validate_required(@required_fields)
|> validate_inclusion(:keying, @keyings)
@ -194,10 +194,9 @@ defmodule Microwaveprop.Beacons.Beacon do
|> validate_number(:frequency_mhz, greater_than: 0)
|> validate_number(:power_mw, 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)
|> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|> MCH.validate_latlon(:lat, :lon)
|> validate_length(:callsign, min: 3, max: 10)
|> validate_grid_format()
|> MCH.validate_grid(:grid)
|> foreign_key_constraint(:user_id)
end
@ -210,40 +209,6 @@ defmodule Microwaveprop.Beacons.Beacon do
defp round_int(v) when is_integer(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".
# Anything else is left for validate_bearing to check as a number.
defp normalize_bearing_change(changeset) do
@ -289,32 +254,4 @@ defmodule Microwaveprop.Beacons.Beacon do
changeset
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

View 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

View file

@ -4,14 +4,13 @@ defmodule Microwaveprop.Propagation do
import Ecto.Query
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Propagation.RunTiming
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.ScoresFile
alias Microwaveprop.Propagation.ScoreStore
alias Microwaveprop.Repo
alias Microwaveprop.Weather.ScalarFile
# ── ML Model Lifecycle ──────────────────────────────────────────────
alias Microwaveprop.Weather.SoundingParams
require Logger
@ -41,6 +40,8 @@ defmodule Microwaveprop.Propagation do
end
else
Logger.info("PropagationML: ML dependencies not available")
# ── Scoring ─────────────────────────────────────────────────────────
:ok
end
end
@ -174,494 +175,30 @@ defmodule Microwaveprop.Propagation do
max(hrrr_rate, nexrad_rate)
end
@doc """
Replace every propagation score for `valid_time` with `scores`.
defdelegate replace_scores(scores, valid_time), to: ScoreStore
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
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)
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
# ── Scoring helpers called from ScoreStore ──────────────────────────
defdelegate latest_valid_time(band_mhz), to: ScoreStore
@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.
Rebuild the factor breakdown for a clicked grid cell by rescoring
the persisted HRRR profile. Made public so ScoreStore can call it
after reading the profile from disk.
"""
@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.
@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 ~45× 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
# ~3060 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
@spec factors_from_profile(non_neg_integer(), DateTime.t(), map(), float(), float()) :: map()
def factors_from_profile(band_mhz, valid_time, profile, lat, lon) do
profile
|> Map.put(:kp_index, current_kp_index())
|> score_grid_point(valid_time, lat, lon)
@ -684,38 +221,10 @@ defmodule Microwaveprop.Propagation do
%{estimated_kp: kp} when is_number(kp) -> trunc(kp)
_ -> nil
end
# ── Run timings ─────────────────────────────────────────────────────
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 """
Record wall-clock duration for a single forecast-hour chain step.
@ -746,6 +255,8 @@ defmodule Microwaveprop.Propagation do
|> Repo.all()
end
# ── Derived factors ─────────────────────────────────────────────────
# Prefer the persisted scalar — `hrrr_profiles` already stored this at
# ingestion time and AsosAdjustmentWorker loads 92k rows per tick without
# the JSONB `profile` column to avoid a Jason.decode! storm on the DB pool.

View 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 ~45× 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
# ~3060 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

View file

@ -5,7 +5,7 @@ defmodule Microwaveprop.Radio.Contact do
import Ecto.Changeset
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -110,15 +110,15 @@ defmodule Microwaveprop.Radio.Contact do
|> cast(attrs, @submission_fields)
|> validate_required(@submission_required)
|> validate_user_or_email()
|> sanitize_callsign(:station1)
|> sanitize_callsign(:station2)
|> sanitize_grid(:grid1)
|> sanitize_grid(:grid2)
|> MCH.normalize_callsign(:station1)
|> MCH.normalize_callsign(:station2)
|> update_change(:grid1, &MCH.normalize_grid/1)
|> update_change(:grid2, &MCH.normalize_grid/1)
|> normalize_blank_mode()
|> validate_callsign(:station1)
|> validate_callsign(:station2)
|> validate_grid_format(:grid1)
|> validate_grid_format(:grid2)
|> MCH.validate_grid(:grid1, blank_error: false)
|> MCH.validate_grid(:grid2, blank_error: false)
|> validate_mode_inclusion()
|> validate_inclusion(:band, @allowed_bands)
|> validate_email_format()
@ -204,34 +204,8 @@ defmodule Microwaveprop.Radio.Contact do
end
end
# Strip whitespace and upcase callsigns
defp sanitize_callsign(changeset, field) do
case get_change(changeset, field) do
nil -> changeset
val -> put_change(changeset, field, val |> String.trim() |> String.upcase())
end
end
# Upcase grid squares (Maidenhead uses uppercase letters + digits)
defp sanitize_grid(changeset, field) do
case get_change(changeset, field) do
nil -> changeset
val -> put_change(changeset, field, val |> String.trim() |> String.upcase())
end
end
# Callsigns: letters, digits, and / only (e.g. W5XD, KG5CCI/P, VE3/W5XD)
defp validate_callsign(changeset, field) do
validate_format(changeset, field, ~r/^[A-Z0-9\/]+$/, message: "must contain only letters, digits, and /")
end
defp validate_grid_format(changeset, field) do
validate_change(changeset, field, fn _, value ->
if Maidenhead.valid?(value) do
[]
else
[{field, "is not a valid Maidenhead grid square"}]
end
end)
end
end

View 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

View file

@ -11,6 +11,7 @@ defmodule Microwaveprop.Rover do
import Ecto.Query
alias Microwaveprop.Accounts.User
alias Microwaveprop.ContextHelpers
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover.FixedStation
@ -56,7 +57,7 @@ defmodule Microwaveprop.Rover do
@spec update_station(User.t(), Ecto.UUID.t(), map()) ::
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_station(%User{} = user, id, attrs) do
case fetch_owned(user, id) do
case ContextHelpers.fetch_owned(FixedStation, id, user) do
{:ok, station} ->
station
|> FixedStation.changeset(attrs)
@ -70,7 +71,7 @@ defmodule Microwaveprop.Rover do
@spec delete_station(User.t(), Ecto.UUID.t()) ::
{:ok, FixedStation.t()} | {:error, :not_found}
def delete_station(%User{} = user, id) do
case fetch_owned(user, id) do
case ContextHelpers.fetch_owned(FixedStation, id, user) do
{:ok, station} ->
Repo.delete(station)
@ -82,7 +83,7 @@ defmodule Microwaveprop.Rover do
@spec toggle_selected(User.t(), Ecto.UUID.t()) ::
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
def toggle_selected(%User{} = user, id) do
case fetch_owned(user, id) do
case ContextHelpers.fetch_owned(FixedStation, id, user) do
{:ok, station} ->
station
|> Ecto.Changeset.change(selected: not station.selected)
@ -117,13 +118,6 @@ defmodule Microwaveprop.Rover do
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) ──────────
@doc """
@ -148,7 +142,7 @@ defmodule Microwaveprop.Rover do
@spec update_location(User.t(), Ecto.UUID.t(), map()) ::
{:ok, Location.t()} | {:error, :not_found | Ecto.Changeset.t()}
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} ->
location
|> Location.changeset(attrs)
@ -162,26 +156,12 @@ defmodule Microwaveprop.Rover do
@spec delete_location(User.t(), Ecto.UUID.t()) ::
{:ok, Location.t()} | {:error, :not_found}
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)
{:error, :not_found} -> {:error, :not_found}
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
query =
from s in FixedStation,
@ -198,14 +178,7 @@ defmodule Microwaveprop.Rover do
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
worker = Microwaveprop.Workers.StationElevationWorker
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
%{id: id} |> worker.new() |> Oban.insert()
end
ContextHelpers.safe_enqueue(Microwaveprop.Workers.StationElevationWorker, %{id: id})
end
end

View file

@ -14,7 +14,7 @@ defmodule Microwaveprop.Rover.FixedStation do
import Ecto.Changeset
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
@callsign_regex ~r/^[A-Z0-9\/]{3,12}$/
@ -40,29 +40,18 @@ defmodule Microwaveprop.Rover.FixedStation do
def changeset(station, attrs) do
station
|> cast(attrs, [:callsign, :grid, :lat, :lon, :elevation_m, :selected, :position])
|> normalize_callsign()
|> MCH.normalize_callsign(:callsign)
|> normalize_grid()
|> validate_required([:callsign])
|> validate_format(:callsign, @callsign_regex)
|> validate_grid_format()
|> derive_latlon_from_grid()
|> MCH.validate_grid(:grid, blank_error: false, normalizer: &normalize_grid_string/1)
|> MCH.derive_latlon(:grid, :lat, :lon)
|> validate_required([:lat, :lon])
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|> MCH.validate_latlon(:lat, :lon)
|> foreign_key_constraint(:user_id)
|> unique_constraint(:callsign, name: :fixed_stations_user_id_callsign_index)
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
case get_change(changeset, :grid) do
nil -> changeset
@ -93,46 +82,4 @@ defmodule Microwaveprop.Rover.FixedStation do
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

View file

@ -11,6 +11,8 @@ defmodule Microwaveprop.Rover.Location do
import Ecto.Changeset
alias Microwaveprop.Radio.MaidenheadChangesetHelpers, as: MCH
@statuses [:good, :bad]
@primary_key {:id, :binary_id, autogenerate: true}
@ -36,8 +38,7 @@ defmodule Microwaveprop.Rover.Location do
location
|> cast(attrs, [:lat, :lon, :status, :notes])
|> validate_required([:lat, :lon, :status])
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|> MCH.validate_latlon(:lat, :lon)
|> validate_inclusion(:status, @statuses)
|> validate_length(:notes, max: 4000)
|> foreign_key_constraint(:user_id)

View file

@ -14,6 +14,7 @@ defmodule Microwaveprop.RoverPlanning do
import Ecto.Query
alias Microwaveprop.Accounts.User
alias Microwaveprop.ContextHelpers
alias Microwaveprop.Repo
alias Microwaveprop.Rover.Location
alias Microwaveprop.RoverPlanning.Mission
@ -81,7 +82,7 @@ defmodule Microwaveprop.RoverPlanning do
@spec update_mission(User.t(), Ecto.UUID.t(), map()) ::
{:ok, Mission.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_mission(%User{} = user, id, attrs) do
case fetch_owned(user, id) do
case ContextHelpers.fetch_owned(Mission, id, user) do
{:ok, mission} ->
mission
|> 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()) ::
{:ok, Mission.t()} | {:error, :not_found}
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)
{:error, :not_found} -> {:error, :not_found}
end
@ -305,18 +306,4 @@ defmodule Microwaveprop.RoverPlanning do
_ = Oban.insert_all(changesets)
:ok
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

View file

@ -2,6 +2,8 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
@moduledoc "Shared new/edit form for beacon records, used by `BeaconLive.Index`."
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@ -158,9 +160,6 @@ defmodule MicrowavepropWeb.BeaconLive.Form do
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("show", beacon), do: ~p"/beacons/#{beacon}"
end

View file

@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
import Ecto.Query
import MicrowavepropWeb.Components.SkewTChart
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1, subscribe: 2]
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.PathAnalysis
@ -83,7 +84,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
socket =
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")
subscribe(socket, "contact_enrichment:#{contact.id}")
kickoff_hydration(socket, contact)
else
@ -215,9 +216,6 @@ defmodule MicrowavepropWeb.ContactLive.Show do
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?(_), do: false

View file

@ -18,7 +18,7 @@ defmodule MicrowavepropWeb.EmeLive do
"""
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.Propagation.BandConfig
@ -64,7 +64,7 @@ defmodule MicrowavepropWeb.EmeLive do
@impl true
def handle_params(params, _uri, socket) do
p = Map.merge(@defaults, Map.take(params, Map.keys(@defaults)))
p = assign_url_params(params, @defaults)
socket =
assign(socket,

View file

@ -8,11 +8,11 @@ defmodule MicrowavepropWeb.ImportLive do
"""
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
alias Microwaveprop.Radio.ImportRun
alias Microwaveprop.Repo
@pubsub Microwaveprop.PubSub
@impl true
def mount(%{"id" => id}, _session, socket) do
case load_run(id) do
@ -24,9 +24,7 @@ defmodule MicrowavepropWeb.ImportLive do
defp mount_authorized(socket, run) do
case authorized_to_view?(socket, run) do
:ok ->
if connected?(socket) do
Phoenix.PubSub.subscribe(@pubsub, "csv_import:#{run.id}")
end
subscribe(socket, "csv_import:#{run.id}")
{:ok,
socket

View file

@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.MapLive do
use LiveStash, stored_keys: [:selected_band, :selected_time]
import MicrowavepropWeb.Layouts, only: [admin_links: 1]
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
@ -97,8 +98,8 @@ defmodule MicrowavepropWeb.MapLive do
defp subscribe_if_connected(socket) do
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
subscribe(socket, "propagation:updated")
subscribe(socket, "propagation:pipeline")
socket = schedule_pipeline_status_timer(socket)
schedule_advance_timer(socket)
else

View file

@ -3,7 +3,7 @@ defmodule MicrowavepropWeb.PathLive do
use MicrowavepropWeb, :live_view
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.Loader, as: BuildingsLoader
@ -31,10 +31,7 @@ defmodule MicrowavepropWeb.PathLive do
@impl true
def mount(_params, _session, socket) do
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end
subscribe(socket, "propagation:updated")
{:ok,
assign(socket,
@ -101,7 +98,7 @@ defmodule MicrowavepropWeb.PathLive do
end
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"

View file

@ -8,7 +8,7 @@ defmodule MicrowavepropWeb.RoverLive do
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.Buildings.Index, as: BuildingsIndex
@ -160,13 +160,6 @@ defmodule MicrowavepropWeb.RoverLive do
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 drive_radius_km(max_distance_mi), do: max_distance_mi * @km_per_mi

View file

@ -8,6 +8,7 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Rover.Location
import Ecto.Query
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
@ -258,9 +259,6 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
value |> Float.round(6) |> Float.to_string()
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

View file

@ -6,6 +6,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
use MicrowavepropWeb, :live_view
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.RoverPlanning.Mission
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
@ -79,16 +81,6 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
|> assign(:options, updated_options)
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
case scope do
%{user: %User{is_admin: true}} -> true

View file

@ -2,6 +2,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
@moduledoc "New / edit form for `/rover-planning` missions."
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [current_user: 1]
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
@ -198,13 +200,6 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
|> Kernel.+(1)
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{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
defp can_modify?(_, _), do: false

View file

@ -6,6 +6,8 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
"""
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Rover
@ -24,10 +26,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|> push_navigate(to: ~p"/rover-planning")}
%Mission{} = mission ->
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
end
subscribe(socket, "rover_planning:#{mission.id}")
{:ok,
assign(socket,

View file

@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.StatusLive do
use MicrowavepropWeb, :live_view
import Ecto.Query
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
alias Microwaveprop.Cache
alias Microwaveprop.Format
@ -15,16 +16,13 @@ defmodule MicrowavepropWeb.StatusLive do
@impl true
def mount(_params, _session, socket) do
_ =
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:contact_status")
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "db:oban_jobs")
# Rust prop-grid-rs emits NOTIFY propagation_ready on completion —
# PropagationNotifyListener fans it out as `propagation:updated` so
# the grid_tasks panel transitions from "running" → "done" without
# a single refresh.
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end
# Rust prop-grid-rs emits NOTIFY propagation_ready on completion —
# PropagationNotifyListener fans it out as `propagation:updated` so
# the grid_tasks panel transitions from "running" → "done" without
# a single refresh.
subscribe(socket, "db:contact_status")
subscribe(socket, "db:oban_jobs")
subscribe(socket, "propagation:updated")
all = fetch_all_stats()

View file

@ -3,6 +3,11 @@ defmodule MicrowavepropWeb.SubmitLive do
use MicrowavepropWeb, :live_view
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.Radio
alias Microwaveprop.Radio.AdifImport
@ -270,11 +275,6 @@ defmodule MicrowavepropWeb.SubmitLive do
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
def render(assigns) do
~H"""
@ -359,10 +359,6 @@ defmodule MicrowavepropWeb.SubmitLive do
"""
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
~H"""
<.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>
"""
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

View 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

View 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

View 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

View file

@ -7,6 +7,8 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
"""
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
alias Microwaveprop.Weather
alias Microwaveprop.Weather.MapLayers
alias MicrowavepropWeb.WeatherMapComponent
@ -24,11 +26,8 @@ defmodule MicrowavepropWeb.WeatherCaMapLive do
@impl true
def mount(params, _session, socket) do
_ =
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
end
subscribe(socket, "weather:updated")
subscribe(socket, "propagation:pipeline")
valid_times = recent_valid_times(Weather.available_hrdps_valid_times())
initial_vt = pick_initial_valid_time(valid_times)

View file

@ -2,6 +2,8 @@ defmodule MicrowavepropWeb.WeatherMapLive do
@moduledoc "`/weather` — HRRR-derived forecast fields (temp, Td, wind, refractivity) on a map."
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.LiveHelpers, only: [subscribe: 2]
alias Microwaveprop.Weather
alias Microwaveprop.Weather.MapLayers
alias MicrowavepropWeb.WeatherMapComponent
@ -26,11 +28,8 @@ defmodule MicrowavepropWeb.WeatherMapLive do
@impl true
def mount(params, _session, socket) do
_ =
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated")
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
end
subscribe(socket, "weather:updated")
subscribe(socket, "propagation:pipeline")
# Default the timeline cursor to the valid_time closest to "now" so
# users land on current conditions, not a +11h forecast hour (which

View file

@ -4,6 +4,10 @@ defmodule MicrowavepropWeb.LiveHelpers do
view-specific logic belongs in its own LiveView.
"""
# ── Parse helpers ────────────────────────────────────────────────────
alias Phoenix.LiveView.Socket
@doc """
Parses a value into a float, returning `default` for nil, empty, or
unparseable input. Accepts strings, numbers, or any value with a
@ -34,4 +38,46 @@ defmodule MicrowavepropWeb.LiveHelpers do
:error -> default
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

View file

@ -50,13 +50,13 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
test "rejects invalid grid1" do
attrs = Map.put(@valid_attrs, :grid1, "ZZ99")
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
test "rejects invalid grid2" do
attrs = Map.put(@valid_attrs, :grid2, "ZZ99")
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
test "accepts valid 6-char grids" do

View file

@ -94,7 +94,7 @@ defmodule MicrowavepropWeb.UserSettingsControllerTest do
response = html_response(conn, 200)
assert response =~ "Home QTH"
assert response =~ "must be a 4 or 6 character"
assert response =~ "is not a valid Maidenhead"
end
end