feat(contacts): add free-form notes field

Operators want a place to jot observations that aren't captured by
the structured fields — weather anecdotes, propagation mode
commentary, equipment details, band conditions. Add a text column
on contacts plus the two ingest paths users submit through:

  * /submit single-contact form gets a 3-row textarea under the
    other fields. Optional, max 2000 chars, with a live length cap
    via maxlength. Whitespace-only input collapses to NULL so the
    column reflects "no notes" rather than an empty string.
  * CSV importer recognises a `notes` (or `note`) column via the
    existing header_aliases table and flows values straight through
    submission_changeset. Sample template gets a matching example
    row with embedded commas so the quoted-field round-trip is
    exercised on download.

ADIF import and the refinement/refinement-notify paths are out of
scope — users specifically asked for CSV + single-contact. Tests
cover the changeset (accept/blank/over-length), CSV header parsing
(plain + RFC-4180 quoted), and LiveView form submit end-to-end.
This commit is contained in:
Graham McIntire 2026-04-23 11:02:22 -05:00
parent f0f4ce6b33
commit cd3950f366
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 144 additions and 8 deletions

View file

@ -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

View file

@ -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)

View file

@ -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"
/>
<div class="mt-4">
<.input
field={@form[:private]}
@ -436,7 +445,9 @@ defmodule MicrowavepropWeb.SubmitLive do
<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</code>
<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.
@ -449,6 +460,10 @@ defmodule MicrowavepropWeb.SubmitLive do
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">

View file

@ -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

View file

@ -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,

1 station1 station2 grid1 grid2 band mode qso_timestamp notes
2 W5XD K5TR EM12kp EM00cd 10000 CW 2024-06-15T14:30:00Z Steady tropo across DFW — signals 20 over
3 N5AC WA5LNL EM12mr EM13qa 24000 SSB 2024-06-15T15:00:00Z Heavy rain scatter, T=45C, copy on sideband
4 KG5CCI W5LUA EM13md EL29fp 47000 CW 2024-07-04T12:00:00Z

View file

@ -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

View file

@ -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

View file

@ -388,5 +388,34 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
assert html =~ "can&#39;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