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