defmodule MicrowavepropWeb.SubmitLive do @moduledoc false use MicrowavepropWeb, :live_view use LiveStash alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Radio alias Microwaveprop.Radio.AdifImport alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.CsvImport alias Microwaveprop.Workers.ContactWeatherEnqueueWorker @band_options BandConfig.band_options() @mode_options ~w(CW SSB FM FT8 FT4 Q65) @impl true def mount(_params, _session, socket) do socket = socket |> allow_upload(:csv, accept: ~w(.csv), max_entries: 1, max_file_size: 10_000_000) |> allow_upload(:adif, accept: :any, max_entries: 1, max_file_size: 10_000_000) changeset = Radio.change_contact(%Contact{}) socket = assign(socket, page_title: "Submit Contact", form: to_form(changeset), band_options: @band_options, mode_options: @mode_options, submitted_at: nil, active_tab: :single, csv_preview: nil, csv_result: nil, csv_email: "" ) case LiveStash.recover_state(socket) do {:recovered, socket} -> {:ok, socket} _ -> {:ok, socket} end end @impl true def handle_event("switch_tab", %{"tab" => tab}, socket) do socket = assign(socket, active_tab: String.to_existing_atom(tab)) {:noreply, LiveStash.stash_assigns(socket, [:active_tab])} end def handle_event("validate", %{"contact" => contact_params}, socket) do changeset = %Contact{} |> Radio.change_contact(contact_params) |> Map.put(:action, :validate) {:noreply, assign(socket, form: to_form(changeset))} end def handle_event("validate_csv", _params, socket) do {:noreply, socket} end def handle_event("validate_adif", _params, socket) do {:noreply, socket} end # Minimum milliseconds between submissions per LiveView session. # For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare). @submission_cooldown_ms 30_000 def handle_event("save", %{"contact" => contact_params}, socket) do if recently_submitted?(socket) do {:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")} else {contact_params, user_id} = merge_user_params(contact_params, socket) case Radio.create_contact(contact_params, user_id) do {:ok, contact} -> ContactWeatherEnqueueWorker.enqueue_for_contact(contact) {:noreply, socket |> assign(submitted_at: System.monotonic_time(:millisecond)) |> put_flash(:info, "Contact submitted successfully!") |> push_navigate(to: ~p"/contacts/#{contact.id}")} {:error, :duplicate, existing} -> {:noreply, socket |> put_flash( :error, "A matching contact already exists: #{existing.station1} ↔ #{existing.station2} on #{existing.band} MHz" ) |> push_navigate(to: ~p"/contacts/#{existing.id}")} {:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))} end end end def handle_event("upload_csv", params, socket) do email = case current_user(socket.assigns) do %{email: user_email} -> user_email _ -> params |> Map.get("submitter_email", "") |> String.trim() end if email == "" do {:noreply, put_flash(socket, :error, "Email is required for CSV upload")} else uploaded_contents = consume_uploaded_entries(socket, :csv, fn %{path: path}, _entry -> {:ok, File.read!(path)} end) case uploaded_contents do [content] -> handle_csv_preview(content, email, socket) [] -> {:noreply, put_flash(socket, :error, "Please select a CSV file")} end end end def handle_event("cancel_csv_upload", %{"ref" => ref}, socket) do {:noreply, cancel_upload(socket, :csv, ref)} end def handle_event("upload_adif", params, socket) do email = case current_user(socket.assigns) do %{email: user_email} -> user_email _ -> params |> Map.get("submitter_email", "") |> String.trim() end if email == "" do {:noreply, put_flash(socket, :error, "Email is required for ADIF upload")} else uploaded_contents = consume_uploaded_entries(socket, :adif, fn %{path: path}, _entry -> {:ok, File.read!(path)} end) case uploaded_contents do [content] -> handle_adif_preview(content, email, socket) [] -> {:noreply, put_flash(socket, :error, "Please select an ADIF file")} end end end def handle_event("cancel_adif_upload", %{"ref" => ref}, socket) do {:noreply, cancel_upload(socket, :adif, ref)} end def handle_event("confirm_csv", _params, socket) do case socket.assigns.csv_preview do %{valid: valid_rows} when valid_rows != [] -> {:ok, commit_result} = CsvImport.commit(valid_rows) {:noreply, socket |> assign(csv_preview: nil, csv_result: commit_result) |> put_flash(:info, "#{length(commit_result.imported)} contacts submitted.")} _ -> {:noreply, put_flash(socket, :error, "Nothing to import.")} end end def handle_event("cancel_csv", _params, socket) do {:noreply, assign(socket, csv_preview: nil, csv_result: nil)} end def handle_event("reset_csv", _params, socket) do {:noreply, assign(socket, csv_preview: nil, csv_result: nil)} end defp handle_adif_preview(content, email, socket) do case AdifImport.preview(content, email) do {:ok, preview} -> {:noreply, assign(socket, csv_preview: preview, csv_result: nil, csv_email: email)} {:error, :no_records} -> {:noreply, put_flash(socket, :error, "ADIF file contains no records")} end end defp handle_csv_preview(content, email, socket) do case CsvImport.preview(content, email) do {:ok, preview} -> {:noreply, assign(socket, csv_preview: preview, csv_result: nil, csv_email: email)} {:error, :empty_csv} -> {:noreply, put_flash(socket, :error, "CSV file is empty")} {:error, :no_data_rows} -> {:noreply, put_flash(socket, :error, "CSV file has no data rows")} end end defp recently_submitted?(socket) do case socket.assigns.submitted_at do nil -> false ts -> System.monotonic_time(:millisecond) - ts < @submission_cooldown_ms end end defp merge_user_params(params, socket) do case current_user(socket.assigns) do %{id: user_id, email: user_email} -> {Map.put(params, "submitter_email", user_email), user_id} _ -> {params, nil} end end defp error_to_string(:too_large), do: "File is too large" defp error_to_string(:too_many_files), do: "Only one file allowed" defp error_to_string(:not_accepted), do: "File type not accepted" defp error_to_string(other), do: to_string(other) @impl true def render(assigns) do ~H""" <.header> Submit Contact <:subtitle>Help us build a better propagation model

Every contact matters

Our propagation model improves with real-world data. Each verified contact you submit is matched against atmospheric conditions at the time of your contact, helping us calibrate predictions for all amateur bands from 902 MHz and up. The more contacts we have, the better our forecasts get for everyone.

Your contact will be available on the <.link navigate="/contacts" class="link link-primary">contacts page shortly after submission, with terrain analysis, atmospheric data, and propagation scoring added automatically.

<%= case @active_tab do %> <% :single -> %> <.single_contact_form form={@form} band_options={@band_options} mode_options={@mode_options} current_user={current_user(assigns)} /> <% :csv -> %> <%= cond do %> <% @csv_result -> %> <.csv_results result={@csv_result} /> <% @csv_preview -> %> <.csv_preview preview={@csv_preview} /> <% true -> %> <.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} /> <% end %> <% :adif -> %> <%= cond do %> <% @csv_result -> %> <.csv_results result={@csv_result} /> <% @csv_preview -> %> <.csv_preview preview={@csv_preview} /> <% true -> %> <.adif_upload_form uploads={@uploads} current_user={current_user(assigns)} /> <% end %> <% end %>
""" end # In-template variant — takes an assigns map directly (not a socket). defp current_user(%{current_scope: %{user: %{} = user}}), do: user defp current_user(_), do: nil defp single_contact_form(assigns) do ~H""" <.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4">
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" required /> <.input field={@form[:grid1]} type="text" label="Grid 1" placeholder="EM12kp" phx-debounce="blur" required /> <.input field={@form[:station2]} type="text" label="Station 2" placeholder="K5TR" required /> <.input field={@form[:grid2]} type="text" label="Grid 2" placeholder="EM00cd" phx-debounce="blur" required />

Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37).

<.input field={@form[:band]} type="select" label="Band" prompt="Select band" options={@band_options} required /> <.input field={@form[:mode]} type="select" label="Mode" prompt="Optional" options={@mode_options} /> <.input field={@form[:qso_timestamp]} type="text" label="Timestamp (UTC, 24h)" placeholder="YYYY-MM-DD HH:MM" pattern="\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?Z?" required />
<%= if @current_user do %> <% else %> <.input field={@form[:submitter_email]} type="email" label="Your Email" placeholder="you@example.com" required /> <% end %>
<.button phx-disable-with="Submitting..." class="btn btn-primary btn-lg w-full sm:w-auto"> <.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Submit Contact
""" end defp csv_upload_form(assigns) do ~H"""

Upload a CSV file with multiple contacts. Columns:

station1, station2, grid1, grid2, band, mode, qso_timestamp
  • 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.
  • Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
  • Band in MHz (e.g. 10000, 24000)
  • Mode is optional — omit the mode column entirely (6 columns total) or leave its value blank.

<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV

{entry.client_name} {entry.progress}%
{error_to_string(err)}
{error_to_string(err)}
<%= if @current_user do %> <% else %>
<% end %>
""" end defp adif_upload_form(assigns) do assigns = assign(assigns, :mode_mapping, AdifImport.mode_mapping_reference()) ~H"""

Upload an ADIF (.adi / .adif) file exported from your logging program.

  • Required fields: CALL, STATION_CALLSIGN (or OPERATOR), GRIDSQUARE, MY_GRIDSQUARE, QSO_DATE, TIME_ON, and FREQ or BAND
  • Only microwave contacts (902 MHz and up) will be imported
  • Sub-microwave contacts are silently skipped
  • Frequencies are fuzzy-matched to the nearest microwave band

Mode handling

ADIF modes are normalized to the six modes we track. SUBMODE takes precedence when present (e.g. MODE=MFSK, SUBMODE=FT8 imports as FT8).

ADIF value Stored as
{adif} {mapped}
{entry.client_name} {entry.progress}%
{error_to_string(err)}
{error_to_string(err)}
<%= if @current_user do %> <% else %>
<% end %>
""" end defp csv_preview(assigns) do assigns = assign(assigns, valid_count: length(assigns.preview.valid), invalid_count: length(assigns.preview.invalid), duplicate_count: length(assigns.preview.duplicates), valid_sample: Enum.take(assigns.preview.valid, 20) ) ~H"""

Review before submitting

Processed {@preview.total_rows} {if @preview.total_rows == 1, do: "row", else: "rows"}. Nothing has been inserted yet — confirm below to import.

<.summary_card tone="success" label="Valid" value={@valid_count} hint="Will be inserted" /> <.summary_card tone="warning" label="Duplicates" value={@duplicate_count} hint="Match existing or earlier rows" /> <.summary_card tone="error" label="Invalid" value={@invalid_count} hint="Skipped — see errors" />
0}>

Invalid rows ({@invalid_count})

Row Errors
Row {row.row_num}
{msg}

100} class="text-xs opacity-60 mt-1"> Showing first 100 of {@invalid_count} invalid rows.

0}>

Duplicates ({@duplicate_count})

Same two callsigns at the same grids on the same band within an hour.

Row Station 1 Station 2 Band Timestamp Source
Row {row.row_num} {row.attrs["station1"]} {row.attrs["grid1"]} {row.attrs["station2"]} {row.attrs["grid2"]} {row.attrs["band"]} {Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")} {duplicate_source(row.reason)}

100} class="text-xs opacity-60 mt-1"> Showing first 100 of {@duplicate_count} duplicates.

0}>

Valid rows ready to import ({@valid_count})

Row Station 1 Station 2 Band Mode Timestamp
Row {row.row_num} {row.attrs["station1"]} {row.attrs["grid1"]} {row.attrs["station2"]} {row.attrs["grid2"]} {row.attrs["band"]} {row.attrs["mode"]} {Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}

length(@valid_sample)} class="text-xs opacity-60 mt-1"> Showing first {length(@valid_sample)} of {@valid_count} valid rows.

""" end attr :tone, :string, required: true attr :label, :string, required: true attr :value, :integer, required: true attr :hint, :string, required: true defp summary_card(assigns) do ~H"""
"border-success/30 bg-success/10" "warning" -> "border-warning/30 bg-warning/10" "error" -> "border-error/30 bg-error/10" _ -> "border-base-300 bg-base-200" end ]}>
{@label}
{@value}
{@hint}
""" end defp duplicate_source(:existing_contact), do: "Already in database" defp duplicate_source(:earlier_in_upload), do: "Earlier row in this upload" defp duplicate_source(_), do: "Duplicate" defp csv_results(assigns) do assigns = assign(assigns, imported_count: length(assigns.result.imported), error_count: length(assigns.result.errors) ) ~H"""
0} class="alert alert-success p-8 text-center" > <.icon name="hero-check-circle" class="w-16 h-16 text-success mx-auto mb-3" />
{@imported_count} {if @imported_count == 1, do: "contact", else: "contacts"} submitted

Weather, terrain, and propagation data will be attached shortly.

0} class="space-y-2">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" /> {@error_count} {if @error_count == 1, do: "row", else: "rows"} had errors at insert time
Row Errors
Row {row_num}
{msg}
""" end end