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 :height1_ft, :integer
|
||||||
field :height2_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
|
belongs_to :user, User
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
|
|
@ -75,7 +80,7 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
@type t :: %__MODULE__{}
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
@required_fields ~w(station1 station2 qso_timestamp band)a
|
@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()
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(contact, attrs) do
|
def changeset(contact, attrs) do
|
||||||
|
|
@ -84,7 +89,7 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
|> validate_required(@required_fields)
|
|> validate_required(@required_fields)
|
||||||
end
|
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
|
@submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a
|
||||||
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
|
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
|
||||||
# Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and
|
# 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_length(:station2, max: 20)
|
||||||
|> validate_height(:height1_ft)
|
|> validate_height(:height1_ft)
|
||||||
|> validate_height(:height2_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
|
end
|
||||||
|
|
||||||
# Sanity bounds on AGL antenna height. Negative heights and anything
|
# Sanity bounds on AGL antenna height. Negative heights and anything
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,12 @@ defmodule Microwaveprop.Radio.CsvImport do
|
||||||
"mode" => "mode",
|
"mode" => "mode",
|
||||||
"qso_timestamp" => "qso_timestamp",
|
"qso_timestamp" => "qso_timestamp",
|
||||||
"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)
|
@required_columns ~w(station1 station2 grid1 grid2 band qso_timestamp)
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,15 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% 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">
|
<div class="mt-4">
|
||||||
<.input
|
<.input
|
||||||
field={@form[:private]}
|
field={@form[:private]}
|
||||||
|
|
@ -436,7 +445,9 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
<div class="alert text-sm">
|
<div class="alert text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-2">Upload a CSV file with multiple contacts. Columns:</p>
|
<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">
|
<ul class="list-disc list-outside pl-5 mt-2 space-y-1 text-base-content/60">
|
||||||
<li>
|
<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.
|
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
|
Mode is <strong>optional</strong> — omit the <code>mode</code> column entirely
|
||||||
(6 columns total) or leave its value blank.
|
(6 columns total) or leave its value blank.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
Notes is <strong>optional</strong> — free-form operator commentary up to
|
||||||
|
2000 characters. Leave the cell blank to store NULL.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
<a href="/downloads/sample_contacts.csv" download class="link link-primary">
|
<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
|
station1,station2,grid1,grid2,band,mode,qso_timestamp,notes
|
||||||
W5XD,K5TR,EM12kp,EM00cd,10000,CW,2024-06-15T14:30:00Z
|
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
|
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
|
KG5CCI,W5LUA,EM13md,EL29fp,47000,CW,2024-07-04T12:00:00Z,
|
||||||
|
|
|
||||||
|
|
|
@ -115,6 +115,31 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
|
||||||
end
|
end
|
||||||
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
|
describe "create_contact/1 deduplication" do
|
||||||
alias Microwaveprop.Radio
|
alias Microwaveprop.Radio
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,34 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
||||||
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
||||||
assert contact.mode == nil
|
assert contact.mode == nil
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "import/2 with invalid data" do
|
describe "import/2 with invalid data" do
|
||||||
|
|
|
||||||
|
|
@ -388,5 +388,34 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
|
||||||
|
|
||||||
assert html =~ "can't be blank"
|
assert html =~ "can't be blank"
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue