diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index 52fab53d..aa5bc16b 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -67,6 +67,11 @@ defmodule Microwaveprop.Radio.Contact do field :height1_ft, :integer field :height2_ft, :integer + # Free-form operator notes captured at submission time (QSO + # commentary, propagation mode observed, equipment details, etc). + # Purely informational — not consumed by the scoring pipeline. + field :notes, :string + belongs_to :user, User timestamps(type: :utc_datetime) @@ -75,7 +80,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 height1_ft height2_ft private)a + @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode height1_ft height2_ft private notes)a @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(contact, attrs) do @@ -84,7 +89,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 height1_ft height2_ft private)a + @submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft private notes)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 @@ -117,6 +122,26 @@ defmodule Microwaveprop.Radio.Contact do |> validate_length(:station2, max: 20) |> validate_height(:height1_ft) |> validate_height(:height2_ft) + |> normalize_blank_notes() + |> validate_length(:notes, max: 2000) + end + + # Collapse whitespace-only notes to `nil` so the DB column reflects + # "no notes" instead of an empty string. Any non-blank value is kept + # as-is (including internal whitespace) so the operator's original + # wording round-trips untouched. + defp normalize_blank_notes(changeset) do + case get_change(changeset, :notes) do + nil -> + changeset + + value -> + if String.trim(value) == "" do + put_change(changeset, :notes, nil) + else + changeset + end + end end # Sanity bounds on AGL antenna height. Negative heights and anything diff --git a/lib/microwaveprop/radio/csv_import.ex b/lib/microwaveprop/radio/csv_import.ex index 6776984f..ccc04828 100644 --- a/lib/microwaveprop/radio/csv_import.ex +++ b/lib/microwaveprop/radio/csv_import.ex @@ -51,7 +51,12 @@ defmodule Microwaveprop.Radio.CsvImport do "mode" => "mode", "qso_timestamp" => "qso_timestamp", "qso timestamp" => "qso_timestamp", - "qso (utc)" => "qso_timestamp" + "qso (utc)" => "qso_timestamp", + # Operator free-form notes. Optional on import; blank cells + # collapse to NULL via Contact.submission_changeset's notes + # normalizer. + "notes" => "notes", + "note" => "notes" } @required_columns ~w(station1 station2 grid1 grid2 band qso_timestamp) diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index 5d9b1a35..16c35028 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -413,6 +413,15 @@ defmodule MicrowavepropWeb.SubmitLive do /> <% end %> + <.input + field={@form[:notes]} + type="textarea" + label="Notes (optional)" + placeholder="Operator notes — propagation mode, weather, equipment, QSO commentary…" + rows="3" + maxlength="2000" + /> +
<.input field={@form[:private]} @@ -436,7 +445,9 @@ defmodule MicrowavepropWeb.SubmitLive do

Upload a CSV file with multiple contacts. Columns:

- station1, station2, grid1, grid2, band, mode, qso_timestamp + + station1, station2, grid1, grid2, band, mode, qso_timestamp, notes +
  • Timestamps in most formats accepted (e.g. 2024-06-15T14:30:00Z, 6/15/2024 2:30 PM, 2024-06-15 14:30). All times assumed UTC. @@ -449,6 +460,10 @@ defmodule MicrowavepropWeb.SubmitLive do Mode is optional — omit the mode column entirely (6 columns total) or leave its value blank.
  • +
  • + Notes is optional — free-form operator commentary up to + 2000 characters. Leave the cell blank to store NULL. +

diff --git a/priv/repo/migrations/20260423155802_add_notes_to_contacts.exs b/priv/repo/migrations/20260423155802_add_notes_to_contacts.exs new file mode 100644 index 00000000..33c1c406 --- /dev/null +++ b/priv/repo/migrations/20260423155802_add_notes_to_contacts.exs @@ -0,0 +1,9 @@ +defmodule Microwaveprop.Repo.Migrations.AddNotesToContacts do + use Ecto.Migration + + def change do + alter table(:contacts) do + add :notes, :text + end + end +end diff --git a/priv/static/downloads/sample_contacts.csv b/priv/static/downloads/sample_contacts.csv index fed35d7e..80815f49 100644 --- a/priv/static/downloads/sample_contacts.csv +++ b/priv/static/downloads/sample_contacts.csv @@ -1,4 +1,4 @@ -station1,station2,grid1,grid2,band,mode,qso_timestamp -W5XD,K5TR,EM12kp,EM00cd,10000,CW,2024-06-15T14:30:00Z -N5AC,WA5LNL,EM12mr,EM13qa,24000,SSB,2024-06-15T15:00:00Z -KG5CCI,W5LUA,EM13md,EL29fp,47000,CW,2024-07-04T12:00:00Z +station1,station2,grid1,grid2,band,mode,qso_timestamp,notes +W5XD,K5TR,EM12kp,EM00cd,10000,CW,2024-06-15T14:30:00Z,Steady tropo across DFW — signals 20 over +N5AC,WA5LNL,EM12mr,EM13qa,24000,SSB,2024-06-15T15:00:00Z,"Heavy rain scatter, T=45C, copy on sideband" +KG5CCI,W5LUA,EM13md,EL29fp,47000,CW,2024-07-04T12:00:00Z, diff --git a/test/microwaveprop/radio/contact_submission_test.exs b/test/microwaveprop/radio/contact_submission_test.exs index a50a608b..2804485a 100644 --- a/test/microwaveprop/radio/contact_submission_test.exs +++ b/test/microwaveprop/radio/contact_submission_test.exs @@ -115,6 +115,31 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do end end + describe "notes field" do + test "accepts a notes string" do + attrs = Map.put(@valid_attrs, :notes, "Rare opening via sporadic-E along the TX/OK front") + changeset = Contact.submission_changeset(%Contact{}, attrs) + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :notes) =~ "sporadic-E" + end + + test "trims whitespace and treats blank notes as nil" do + for blank <- ["", " ", "\n\t "] do + attrs = Map.put(@valid_attrs, :notes, blank) + changeset = Contact.submission_changeset(%Contact{}, attrs) + assert changeset.valid?, "expected blank notes #{inspect(blank)} to validate" + refute Ecto.Changeset.get_change(changeset, :notes) in [blank] + end + end + + test "rejects notes longer than 2000 characters" do + attrs = Map.put(@valid_attrs, :notes, String.duplicate("x", 2001)) + changeset = Contact.submission_changeset(%Contact{}, attrs) + refute changeset.valid? + assert errors_on(changeset)[:notes] + end + end + describe "create_contact/1 deduplication" do alias Microwaveprop.Radio diff --git a/test/microwaveprop/radio/csv_import_test.exs b/test/microwaveprop/radio/csv_import_test.exs index c616b338..cdb379f2 100644 --- a/test/microwaveprop/radio/csv_import_test.exs +++ b/test/microwaveprop/radio/csv_import_test.exs @@ -146,6 +146,34 @@ defmodule Microwaveprop.Radio.CsvImportTest do assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) assert contact.mode == nil end + + test "accepts an 8-column CSV with a notes column" do + csv = + "station1,station2,grid1,grid2,band,mode,qso_timestamp,notes\n" <> + "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z,Strong Es opening" + + assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) + assert contact.notes == "Strong Es opening" + end + + test "accepts a notes column with embedded commas and quotes" do + # RFC 4180 quoted field: "He said ""hi"" and signed, 73" + csv = + "station1,station2,grid1,grid2,band,mode,qso_timestamp,notes\n" <> + ~s(W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z,"He said ""hi"" and signed, 73") + + assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) + assert contact.notes == ~s(He said "hi" and signed, 73) + end + + test "blank notes cell stores NULL, not empty string" do + csv = + "station1,station2,grid1,grid2,band,mode,qso_timestamp,notes\n" <> + "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z," + + assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter) + assert contact.notes == nil + end end describe "import/2 with invalid data" do diff --git a/test/microwaveprop_web/live/submit_live_test.exs b/test/microwaveprop_web/live/submit_live_test.exs index f6884ed7..1f524bf7 100644 --- a/test/microwaveprop_web/live/submit_live_test.exs +++ b/test/microwaveprop_web/live/submit_live_test.exs @@ -388,5 +388,34 @@ defmodule MicrowavepropWeb.SubmitLiveTest do assert html =~ "can't be blank" end + + test "persists notes from the form on save", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/submit") + + lv + |> form("#contact-form", + contact: %{ + station1: "W5XD", + station2: "K5TR", + qso_timestamp: "2026-03-28T18:00", + mode: "CW", + band: "1296", + grid1: "EM12", + grid2: "EM00", + submitter_email: "test@example.com", + notes: "Tropo duct along the coast" + } + ) + |> render_submit() + + contact = Repo.one!(Contact) + assert contact.notes == "Tropo duct along the coast" + end + + test "renders a notes textarea on the single-contact tab", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/submit") + assert html =~ ~s(name="contact[notes]") + assert html =~ "textarea" + end end end