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:
parent
f0f4ce6b33
commit
cd3950f366
8 changed files with 144 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue