diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index d5cacd1b..7e21833d 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -10,6 +10,7 @@ defmodule Microwaveprop.Radio do alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo + alias Microwaveprop.Workers.ContactWeatherEnqueueWorker @per_page 20 @sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a @@ -772,6 +773,8 @@ defmodule Microwaveprop.Radio do |> normalize_string_field("grid1") |> normalize_string_field("grid2") |> normalize_string_field("mode") + |> normalize_integer_field("height1_ft") + |> normalize_integer_field("height2_ft") end defp normalize_string_field(map, key) do @@ -782,6 +785,33 @@ defmodule Microwaveprop.Radio do end end + # Forms deliver integer-shaped inputs as strings. Drop the field entirely + # on empty strings so the diff doesn't register "" != current_value. + defp normalize_integer_field(map, key) do + case Map.get(map, key, :not_provided) do + :not_provided -> + map + + nil -> + map + + val when is_integer(val) -> + map + + "" -> + Map.delete(map, key) + + val when is_binary(val) -> + case Integer.parse(String.trim(val)) do + {int, ""} -> Map.put(map, key, int) + _ -> map + end + + _ -> + map + end + end + @doc false @spec diff_against_contact(Contact.t(), map()) :: map() def diff_against_contact(contact, proposed) do @@ -807,6 +837,8 @@ defmodule Microwaveprop.Radio do end defp current_value(contact, "qso_timestamp"), do: contact.qso_timestamp + defp current_value(contact, "height1_ft"), do: contact.height1_ft + defp current_value(contact, "height2_ft"), do: contact.height2_ft defp current_value(_contact, _key), do: nil defp values_equal?(a, b) when is_binary(a) and is_binary(b) do @@ -951,10 +983,36 @@ defmodule Microwaveprop.Radio do def apply_edit_to_contact(contact, proposed_changes) do changes = build_contact_changes(contact, proposed_changes) changes = maybe_recompute_positions(changes, contact, proposed_changes) + changes = maybe_reset_terrain_for_heights(changes, proposed_changes) - contact - |> Ecto.Changeset.change(changes) - |> Repo.update!() + updated = + contact + |> Ecto.Changeset.change(changes) + |> Repo.update!() + + maybe_enqueue_terrain_for_heights(updated, proposed_changes) + + updated + end + + defp maybe_enqueue_terrain_for_heights(contact, proposed) do + if Map.has_key?(proposed, "height1_ft") or Map.has_key?(proposed, "height2_ft") do + ContactWeatherEnqueueWorker.enqueue_for_contact(contact, [:terrain]) + end + + :ok + end + + # Antenna heights feed directly into the elevation-profile terrain + # analysis (diffraction loss, clearance verdict). Changing them + # invalidates the stored terrain_profile, so reset the status to + # :pending and let TerrainProfileWorker re-run. + defp maybe_reset_terrain_for_heights(changes, proposed) do + if Map.has_key?(proposed, "height1_ft") or Map.has_key?(proposed, "height2_ft") do + Map.put(changes, :terrain_status, :pending) + else + changes + end end defp maybe_recompute_positions(changes, contact, proposed) do diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index 78138689..c2a83011 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -60,6 +60,12 @@ defmodule Microwaveprop.Radio.Contact do field :submitter_email, :string field :flagged_invalid, :boolean, default: false + # Antenna height above ground level (feet). Optional — when set, the + # elevation profile and terrain analysis use the actual heights + # instead of a default 10 ft. + field :height1_ft, :integer + field :height2_ft, :integer + belongs_to :user, User timestamps(type: :utc_datetime) @@ -68,7 +74,7 @@ defmodule Microwaveprop.Radio.Contact do @type t :: %__MODULE__{} @required_fields ~w(station1 station2 qso_timestamp band)a - @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode)a + @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode height1_ft height2_ft)a @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(contact, attrs) do @@ -77,7 +83,7 @@ defmodule Microwaveprop.Radio.Contact do |> validate_required(@required_fields) end - @submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode)a + @submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft)a @submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a @allowed_modes ~w(CW SSB FM FT8 FT4 Q65) # Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and @@ -108,6 +114,14 @@ defmodule Microwaveprop.Radio.Contact do |> validate_length(:submitter_email, max: 254) |> validate_length(:station1, max: 20) |> validate_length(:station2, max: 20) + |> validate_height(:height1_ft) + |> validate_height(:height2_ft) + end + + # Sanity bounds on AGL antenna height. Negative heights and anything + # taller than ~1000 ft are almost certainly data-entry errors. + defp validate_height(changeset, field) do + validate_number(changeset, field, greater_than_or_equal_to: 0, less_than_or_equal_to: 1000) end # Treat empty / whitespace-only mode strings as "not provided" so the column diff --git a/lib/microwaveprop/radio/contact_edit.ex b/lib/microwaveprop/radio/contact_edit.ex index 520f5bed..55f58b49 100644 --- a/lib/microwaveprop/radio/contact_edit.ex +++ b/lib/microwaveprop/radio/contact_edit.ex @@ -29,7 +29,7 @@ defmodule Microwaveprop.Radio.ContactEdit do @type t :: %__MODULE__{} - @editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp) + @editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp height1_ft height2_ft) @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(edit, attrs) do @@ -118,8 +118,21 @@ defmodule Microwaveprop.Radio.ContactEdit do end end + defp validate_field_value("height1_ft", cs), do: validate_height_value(cs, "height1_ft") + defp validate_field_value("height2_ft", cs), do: validate_height_value(cs, "height2_ft") + defp validate_field_value(_, changeset), do: changeset + defp validate_height_value(changeset, field) do + val = get_field(changeset, :proposed_changes)[field] + + if is_integer(val) and val >= 0 and val <= 1000 do + changeset + else + add_error(changeset, :proposed_changes, "#{field} must be an integer between 0 and 1000") + end + end + defp validate_callsign_value(changeset, field) do val = get_field(changeset, :proposed_changes)[field] || "" val = val |> String.trim() |> String.upcase() diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index f085883c..9adcbd5c 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -262,7 +262,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do "band" => if(contact.band, do: Decimal.to_string(contact.band), else: ""), "mode" => contact.mode, "qso_timestamp" => - if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: "") + if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: ""), + "height1_ft" => contact.height1_ft, + "height2_ft" => contact.height2_ft } {:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))} @@ -836,7 +838,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
{if direct_edit, - do: "Change any fields below. Changes will be applied immediately.", + do: "Change any fields below. Changes will be submitted for review.", else: "Change any fields below. Only fields you modify will be submitted for review."}
<.form @@ -846,11 +848,27 @@ defmodule MicrowavepropWeb.ContactLive.Show do phx-submit="submit_edit" class="space-y-4" > -Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37). + Antenna heights are optional but improve terrain-clearance analysis.