Adds Microwaveprop.Propagation.AsosNudge: a pure IDW bias-field module that takes ASOS observations + HRRR profiles and returns re-scored grid rows for every cell within 250km of a reporting station. Upper-air fields (min_refractivity_gradient, pwat_mm, hpbl_m, profile, duct metadata) pass through unchanged so HRRR's signal isn't clobbered. The old AsosAdjustmentWorker was unwired and buggy — nil'd out ~22% of the scoring weight and wrote orphan timestamps. Replaced with a slim worker that queries the latest HRRR valid_time, fetches live ASOS currents, calls AsosNudge.compute/3, and upserts onto (lat, lon, valid_time, band_mhz) so nudged values overwrite the HRRR hour cleanly instead of polluting available_valid_times. After each upsert it warms ScoreCache and broadcasts propagation:updated so live /map clients refresh. Cron hooked up every 10 minutes in config.exs and dev.exs. Also cleaned up the stale "dev has propagation disabled" note in CLAUDE.md. 13 new AsosNudge unit tests cover: residual computation (co-located, out-of-grid, nil fields), IDW weighting (single station, far station, two equidistant stations, nil component handling), upper-air preservation, and the compute/3 entry point's shape and radius filter. Drive-by Styler formatting touched a handful of unrelated files from `mix format`.
854 lines
28 KiB
Elixir
854 lines
28 KiB
Elixir
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"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
|
<.header>
|
|
Submit Contact
|
|
<:subtitle>Help us build a better propagation model</:subtitle>
|
|
</.header>
|
|
|
|
<div class="alert alert-info mb-6 text-sm leading-relaxed">
|
|
<div>
|
|
<p class="font-semibold mb-1">Every contact matters</p>
|
|
<p>
|
|
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.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert mb-6 text-sm leading-relaxed">
|
|
<p>
|
|
Your contact will be available on the
|
|
<.link navigate="/contacts" class="link link-primary">contacts page</.link>
|
|
shortly after submission, with terrain analysis, atmospheric data, and propagation scoring
|
|
added automatically.
|
|
</p>
|
|
</div>
|
|
|
|
<div role="tablist" class="tabs tabs-box mb-6">
|
|
<button
|
|
role="tab"
|
|
class={["tab", @active_tab == :single && "tab-active"]}
|
|
phx-click="switch_tab"
|
|
phx-value-tab="single"
|
|
>
|
|
Single Contact
|
|
</button>
|
|
<button
|
|
role="tab"
|
|
class={["tab", @active_tab == :csv && "tab-active"]}
|
|
phx-click="switch_tab"
|
|
phx-value-tab="csv"
|
|
>
|
|
Upload CSV
|
|
</button>
|
|
<button
|
|
role="tab"
|
|
class={["tab", @active_tab == :adif && "tab-active"]}
|
|
phx-click="switch_tab"
|
|
phx-value-tab="adif"
|
|
>
|
|
Upload ADIF
|
|
</button>
|
|
</div>
|
|
|
|
<%= 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 %>
|
|
</Layouts.app>
|
|
"""
|
|
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">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-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
|
|
/>
|
|
</div>
|
|
|
|
<p class="text-sm text-base-content/60 -mt-2">
|
|
Be as specific as possible with grid squares (8 characters preferred, e.g. EM12kp37).
|
|
</p>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<.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="datetime-local"
|
|
label="Timestamp (UTC)"
|
|
lang="en-GB"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<%= if @current_user do %>
|
|
<input
|
|
type="hidden"
|
|
name="contact[submitter_email]"
|
|
value={@current_user.email}
|
|
/>
|
|
<% else %>
|
|
<.input
|
|
field={@form[:submitter_email]}
|
|
type="email"
|
|
label="Your Email"
|
|
placeholder="you@example.com"
|
|
required
|
|
/>
|
|
<% end %>
|
|
|
|
<div class="mt-6">
|
|
<.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
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
"""
|
|
end
|
|
|
|
defp csv_upload_form(assigns) do
|
|
~H"""
|
|
<div class="space-y-6">
|
|
<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>
|
|
<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.
|
|
</li>
|
|
<li>
|
|
Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
|
|
</li>
|
|
<li>Band in MHz (e.g. 10000, 24000)</li>
|
|
<li>
|
|
Mode is <strong>optional</strong> — omit the <code>mode</code> column entirely
|
|
(6 columns total) or leave its value blank.
|
|
</li>
|
|
</ul>
|
|
<p class="mt-2">
|
|
<a href="/downloads/sample_contacts.csv" download class="link link-primary">
|
|
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> Download sample CSV
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="csv-upload-form" phx-submit="upload_csv" phx-change="validate_csv" class="space-y-4">
|
|
<div class="fieldset mb-2">
|
|
<label>
|
|
<span class="label mb-1">CSV File</span>
|
|
<.live_file_input upload={@uploads.csv} class="file-input file-input-bordered w-full" />
|
|
</label>
|
|
</div>
|
|
|
|
<div :for={entry <- @uploads.csv.entries} class="space-y-1">
|
|
<div class="flex items-center justify-between text-xs tabular-nums">
|
|
<span class="truncate pr-2">{entry.client_name}</span>
|
|
<span class="flex items-center gap-2 shrink-0">
|
|
<span>{entry.progress}%</span>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
phx-click="cancel_csv_upload"
|
|
phx-value-ref={entry.ref}
|
|
aria-label="Cancel upload"
|
|
>
|
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<progress
|
|
class="progress progress-primary w-full"
|
|
value={entry.progress}
|
|
max="100"
|
|
>
|
|
</progress>
|
|
<div :for={err <- upload_errors(@uploads.csv, entry)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
</div>
|
|
|
|
<div :for={err <- upload_errors(@uploads.csv)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
|
|
<%= if @current_user do %>
|
|
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
|
<% else %>
|
|
<div class="fieldset mb-2">
|
|
<label>
|
|
<span class="label mb-1">Your Email</span>
|
|
<input
|
|
type="email"
|
|
name="submitter_email"
|
|
class="w-full input"
|
|
placeholder="you@example.com"
|
|
value=""
|
|
/>
|
|
</label>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="mt-6">
|
|
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp adif_upload_form(assigns) do
|
|
assigns = assign(assigns, :mode_mapping, AdifImport.mode_mapping_reference())
|
|
|
|
~H"""
|
|
<div class="space-y-6">
|
|
<div class="alert text-sm">
|
|
<div>
|
|
<p class="mb-2">
|
|
Upload an ADIF (.adi / .adif) file exported from your logging program.
|
|
</p>
|
|
<ul class="list-disc list-outside pl-5 mt-2 space-y-1 text-base-content/60">
|
|
<li>
|
|
Required fields: <code>CALL</code>, <code>STATION_CALLSIGN</code>
|
|
(or <code>OPERATOR</code>), <code>GRIDSQUARE</code>, <code>MY_GRIDSQUARE</code>, <code>QSO_DATE</code>, <code>TIME_ON</code>,
|
|
and <code>FREQ</code>
|
|
or <code>BAND</code>
|
|
</li>
|
|
<li>Only microwave contacts (902 MHz and up) will be imported</li>
|
|
<li>Sub-microwave contacts are silently skipped</li>
|
|
<li>Frequencies are fuzzy-matched to the nearest microwave band</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert text-sm">
|
|
<div class="w-full">
|
|
<p class="font-semibold mb-2">Mode handling</p>
|
|
<p class="mb-2 text-base-content/70">
|
|
ADIF modes are normalized to the six modes we track. SUBMODE takes
|
|
precedence when present (e.g. <code>MODE=MFSK, SUBMODE=FT8</code> imports as FT8).
|
|
</p>
|
|
<table class="table table-xs mt-2">
|
|
<thead>
|
|
<tr>
|
|
<th>ADIF value</th>
|
|
<th>Stored as</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={{adif, mapped} <- @mode_mapping}>
|
|
<td><code>{adif}</code></td>
|
|
<td><code>{mapped}</code></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
id="adif-upload-form"
|
|
phx-submit="upload_adif"
|
|
phx-change="validate_adif"
|
|
class="space-y-4"
|
|
>
|
|
<div class="fieldset mb-2">
|
|
<label>
|
|
<span class="label mb-1">ADIF File</span>
|
|
<.live_file_input upload={@uploads.adif} class="file-input file-input-bordered w-full" />
|
|
</label>
|
|
</div>
|
|
|
|
<div :for={entry <- @uploads.adif.entries} class="space-y-1">
|
|
<div class="flex items-center justify-between text-xs tabular-nums">
|
|
<span class="truncate pr-2">{entry.client_name}</span>
|
|
<span class="flex items-center gap-2 shrink-0">
|
|
<span>{entry.progress}%</span>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
phx-click="cancel_adif_upload"
|
|
phx-value-ref={entry.ref}
|
|
aria-label="Cancel upload"
|
|
>
|
|
<.icon name="hero-x-mark" class="w-4 h-4" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<progress
|
|
class="progress progress-primary w-full"
|
|
value={entry.progress}
|
|
max="100"
|
|
>
|
|
</progress>
|
|
<div :for={err <- upload_errors(@uploads.adif, entry)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
</div>
|
|
|
|
<div :for={err <- upload_errors(@uploads.adif)} class="text-xs text-error">
|
|
{error_to_string(err)}
|
|
</div>
|
|
|
|
<%= if @current_user do %>
|
|
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
|
<% else %>
|
|
<div class="fieldset mb-2">
|
|
<label>
|
|
<span class="label mb-1">Your Email</span>
|
|
<input
|
|
type="email"
|
|
name="submitter_email"
|
|
class="w-full input"
|
|
placeholder="you@example.com"
|
|
value=""
|
|
/>
|
|
</label>
|
|
</div>
|
|
<% end %>
|
|
|
|
<div class="mt-6">
|
|
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
|
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
"""
|
|
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"""
|
|
<div class="space-y-6">
|
|
<div>
|
|
<h2 class="text-xl font-bold mb-2">Review before submitting</h2>
|
|
<p class="text-sm opacity-70">
|
|
Processed {@preview.total_rows} {if @preview.total_rows == 1, do: "row", else: "rows"}.
|
|
Nothing has been inserted yet — confirm below to import.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<.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"
|
|
/>
|
|
</div>
|
|
|
|
<div :if={@invalid_count > 0}>
|
|
<h3 class="font-semibold mb-2">Invalid rows ({@invalid_count})</h3>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-24">Row</th>
|
|
<th>Errors</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- Enum.take(@preview.invalid, 100)}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>
|
|
<div :for={msg <- row.messages}>{msg}</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@invalid_count > 100} class="text-xs opacity-60 mt-1">
|
|
Showing first 100 of {@invalid_count} invalid rows.
|
|
</p>
|
|
</div>
|
|
|
|
<div :if={@duplicate_count > 0}>
|
|
<h3 class="font-semibold mb-2">Duplicates ({@duplicate_count})</h3>
|
|
<p class="text-xs opacity-60 mb-2">
|
|
Same two callsigns at the same grids on the same band within an hour.
|
|
</p>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-20">Row</th>
|
|
<th>Station 1</th>
|
|
<th>Station 2</th>
|
|
<th>Band</th>
|
|
<th>Timestamp</th>
|
|
<th>Source</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- Enum.take(@preview.duplicates, 100)}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
<td class="font-mono text-xs">
|
|
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
</td>
|
|
<td class="text-xs opacity-70">{duplicate_source(row.reason)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@duplicate_count > 100} class="text-xs opacity-60 mt-1">
|
|
Showing first 100 of {@duplicate_count} duplicates.
|
|
</p>
|
|
</div>
|
|
|
|
<div :if={@valid_count > 0}>
|
|
<h3 class="font-semibold mb-2">Valid rows ready to import ({@valid_count})</h3>
|
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="w-20">Row</th>
|
|
<th>Station 1</th>
|
|
<th>Station 2</th>
|
|
<th>Band</th>
|
|
<th>Mode</th>
|
|
<th>Timestamp</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- @valid_sample}>
|
|
<td class="font-mono">Row {row.row_num}</td>
|
|
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
|
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
|
<td class="tabular-nums">{row.attrs["band"]}</td>
|
|
<td>{row.attrs["mode"]}</td>
|
|
<td class="font-mono text-xs">
|
|
{Calendar.strftime(row.timestamp, "%Y-%m-%d %H:%M UTC")}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p :if={@valid_count > length(@valid_sample)} class="text-xs opacity-60 mt-1">
|
|
Showing first {length(@valid_sample)} of {@valid_count} valid rows.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
|
|
<button
|
|
:if={@valid_count > 0}
|
|
type="button"
|
|
class="btn btn-primary btn-lg"
|
|
phx-click="confirm_csv"
|
|
phx-disable-with="Importing..."
|
|
data-confirm={"Import #{@valid_count} contacts?"}
|
|
>
|
|
<.icon name="hero-check" class="w-5 h-5" />
|
|
Looks good — submit {@valid_count} {if @valid_count == 1,
|
|
do: "contact",
|
|
else: "contacts"}
|
|
</button>
|
|
<button type="button" class="btn btn-ghost" phx-click="cancel_csv">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
"""
|
|
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"""
|
|
<div class={[
|
|
"rounded-box border p-4",
|
|
case @tone do
|
|
"success" -> "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
|
|
]}>
|
|
<div class="text-xs uppercase tracking-wider opacity-70">{@label}</div>
|
|
<div class="text-3xl font-bold tabular-nums">{@value}</div>
|
|
<div class="text-xs opacity-60">{@hint}</div>
|
|
</div>
|
|
"""
|
|
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"""
|
|
<div class="space-y-6">
|
|
<div
|
|
:if={@imported_count > 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" />
|
|
<div class="text-3xl font-bold tabular-nums">
|
|
{@imported_count} {if @imported_count == 1, do: "contact", else: "contacts"} submitted
|
|
</div>
|
|
<p class="text-sm opacity-70 mt-2">
|
|
Weather, terrain, and propagation data will be attached shortly.
|
|
</p>
|
|
</div>
|
|
|
|
<div :if={@error_count > 0} class="space-y-2">
|
|
<div class="alert alert-error">
|
|
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
|
<span>
|
|
{@error_count} {if @error_count == 1, do: "row", else: "rows"} had errors at insert time
|
|
</span>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Row</th>
|
|
<th>Errors</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={{row_num, messages} <- @result.errors}>
|
|
<td class="font-mono">Row {row_num}</td>
|
|
<td>
|
|
<div :for={msg <- messages}>{msg}</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="button" class="btn btn-outline" phx-click="reset_csv">
|
|
Upload another
|
|
</button>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|