Cache /contacts, fix map blink, make mode optional
- Add contacts_inserted_at_desc_id_desc_idx so /contacts list runs as an Index Scan (~0.4ms) instead of a Seq Scan + top-N heapsort (~77ms on 58k rows) - Add Microwaveprop.Cache: tiny ETS-backed TTL cache for memoizing expensive-but-stale-tolerant values - Cache total_entries for unsearched /contacts page loads (30s TTL, invalidated on insert) - Cache the entire /contacts/map payload (pre-encoded JSON + band list), moving load_contacts into Radio.contact_map_payload; invalidated on new contact insert. Mount goes from ~150-300ms to ~5ms. - Fix /map overlay blinking on load: reuse the Leaflet GridLayer across renderScores calls and just redraw() against the updated ScoreGrid, instead of removeLayer + new layer which flashed between tile regens - Make mode optional on submission: schema change (null: true), submission_changeset no longer requires it, blank strings normalise to nil, CSV import accepts 6-column (no mode) or 7-column layouts - core_components .input adds a red asterisk to labels when required, giving users a visual cue for which fields must be filled on /submit
This commit is contained in:
parent
8b9d0a29dc
commit
6c334d6e18
16 changed files with 379 additions and 120 deletions
|
|
@ -979,10 +979,14 @@ export const PropagationMap: Record<string, unknown> & {
|
|||
|
||||
renderScores(this: PropagationMapHook, scores: ScorePoint[]) {
|
||||
this.scores = scores
|
||||
if (this.scoreOverlay) {
|
||||
this.map.removeLayer(this.scoreOverlay)
|
||||
|
||||
if (scores.length === 0) {
|
||||
if (this.scoreOverlay) {
|
||||
this.map.removeLayer(this.scoreOverlay)
|
||||
this.scoreOverlay = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (scores.length === 0) return
|
||||
|
||||
if (!this.scoreGrid) {
|
||||
this.scoreGrid = new ScoreGrid()
|
||||
|
|
@ -991,7 +995,15 @@ export const PropagationMap: Record<string, unknown> & {
|
|||
}
|
||||
scores.forEach((s) => this.scoreGrid.put(s.lat, s.lon, s.score))
|
||||
|
||||
// Pre-calculate RGBA strings for scores 0-100
|
||||
if (this.scoreOverlay) {
|
||||
// Layer already on the map — just redraw existing tiles against the
|
||||
// updated grid data. Avoids the remove/add flash that caused the
|
||||
// overlay to blink during the initial load pipeline.
|
||||
this.scoreOverlay.redraw()
|
||||
return
|
||||
}
|
||||
|
||||
// First render — build the GridLayer once and reuse it for every update.
|
||||
const colorCache = new Array<string>(101)
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
const c = this.scoreColorRGB(i)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ config :microwaveprop, nexrad_req_options: [plug: {Req.Test, Microwaveprop.Weath
|
|||
config :microwaveprop, solar_req_options: [plug: {Req.Test, Microwaveprop.Weather.SolarClient}]
|
||||
config :microwaveprop, srtm_req_options: [plug: {Req.Test, Microwaveprop.Terrain.Srtm}, retry: false]
|
||||
config :microwaveprop, start_freshness_monitor: false
|
||||
config :microwaveprop, cache_contact_count: false
|
||||
config :microwaveprop, cache_contact_map: false
|
||||
|
||||
# Initialize plugs at runtime for faster test compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ defmodule Microwaveprop.Application do
|
|||
Microwaveprop.Repo,
|
||||
{Cluster.Supervisor, [topologies, [name: Microwaveprop.ClusterSupervisor]]},
|
||||
{Phoenix.PubSub, name: Microwaveprop.PubSub},
|
||||
Microwaveprop.Cache,
|
||||
Microwaveprop.Propagation.ScoreCache,
|
||||
Microwaveprop.Weather.NexradCache,
|
||||
{Oban, Application.fetch_env!(:microwaveprop, Oban)},
|
||||
|
|
|
|||
71
lib/microwaveprop/cache.ex
Normal file
71
lib/microwaveprop/cache.ex
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
defmodule Microwaveprop.Cache do
|
||||
@moduledoc """
|
||||
Tiny ETS-backed TTL cache for values that are expensive to compute but
|
||||
tolerate short staleness. Used for things like `Repo.aggregate` counts that
|
||||
would otherwise run on every page load.
|
||||
|
||||
Not a replacement for `Microwaveprop.Propagation.ScoreCache` or
|
||||
`Microwaveprop.Weather.NexradCache` — those have bespoke invalidation logic
|
||||
driven by PubSub. This module is for generic time-boxed memoization.
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
@table :microwaveprop_cache
|
||||
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return the cached value for `key` if it's still fresh, otherwise invoke
|
||||
`fun`, store the result with `ttl_ms` lifetime, and return it.
|
||||
"""
|
||||
@spec fetch_or_store(term(), non_neg_integer(), (-> value)) :: value when value: term()
|
||||
def fetch_or_store(key, ttl_ms, fun) when is_function(fun, 0) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
case :ets.lookup(@table, key) do
|
||||
[{_, value, expires_at}] when expires_at > now ->
|
||||
value
|
||||
|
||||
_ ->
|
||||
value = fun.()
|
||||
:ets.insert(@table, {key, value, now + ttl_ms})
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Insert `value` directly, overwriting any existing entry for `key`."
|
||||
@spec put(term(), term(), integer()) :: :ok
|
||||
def put(key, value, ttl_ms) do
|
||||
:ets.insert(@table, {key, value, System.monotonic_time(:millisecond) + ttl_ms})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc "Remove `key` from the cache, forcing the next fetch to recompute."
|
||||
@spec invalidate(term()) :: :ok
|
||||
def invalidate(key) do
|
||||
:ets.delete(@table, key)
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec clear() :: :ok
|
||||
def clear do
|
||||
:ets.delete_all_objects(@table)
|
||||
:ok
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
:ets.new(@table, [
|
||||
:set,
|
||||
:named_table,
|
||||
:public,
|
||||
read_concurrency: true,
|
||||
write_concurrency: true
|
||||
])
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
end
|
||||
|
|
@ -4,6 +4,7 @@ defmodule Microwaveprop.Radio do
|
|||
import Ecto.Query
|
||||
|
||||
alias Microwaveprop.Accounts.User
|
||||
alias Microwaveprop.Cache
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Radio.ContactEdit
|
||||
alias Microwaveprop.Radio.Maidenhead
|
||||
|
|
@ -11,6 +12,10 @@ defmodule Microwaveprop.Radio do
|
|||
|
||||
@per_page 20
|
||||
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a
|
||||
@count_cache_key {__MODULE__, :total_contact_count}
|
||||
@count_cache_ttl_ms 30_000
|
||||
@map_payload_cache_key {__MODULE__, :contact_map_payload}
|
||||
@map_payload_ttl_ms 10 * 60 * 1_000
|
||||
|
||||
@type contact_page :: %{
|
||||
entries: [Contact.t()],
|
||||
|
|
@ -20,6 +25,95 @@ defmodule Microwaveprop.Radio do
|
|||
total_entries: non_neg_integer()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Pre-encoded JSON payload for `/contacts/map`. Computing this requires loading
|
||||
every contact with coordinates, deriving formatted rows, deduplicating
|
||||
reciprocals, and JSON-encoding ~58k rows — ~150-300ms of work that would
|
||||
otherwise run on every page mount. Cached for 10 minutes, invalidated when
|
||||
a new contact is inserted.
|
||||
"""
|
||||
@spec contact_map_payload() :: %{
|
||||
json: iodata(),
|
||||
count: non_neg_integer(),
|
||||
bands: [integer()]
|
||||
}
|
||||
def contact_map_payload do
|
||||
if Application.get_env(:microwaveprop, :cache_contact_map, true) do
|
||||
Cache.fetch_or_store(@map_payload_cache_key, @map_payload_ttl_ms, fn ->
|
||||
build_contact_map_payload()
|
||||
end)
|
||||
else
|
||||
build_contact_map_payload()
|
||||
end
|
||||
end
|
||||
|
||||
defp build_contact_map_payload do
|
||||
contacts = load_contacts_for_map()
|
||||
bands = contacts |> Enum.map(fn [_, _, _, _, band | _] -> band end) |> Enum.uniq() |> Enum.sort()
|
||||
%{json: Jason.encode_to_iodata!(contacts), count: length(contacts), bands: bands}
|
||||
end
|
||||
|
||||
defp load_contacts_for_map do
|
||||
from(c in Contact,
|
||||
where: not is_nil(c.pos1) and not is_nil(c.pos2),
|
||||
select: %{
|
||||
id: c.id,
|
||||
pos1: c.pos1,
|
||||
pos2: c.pos2,
|
||||
band: c.band,
|
||||
station1: c.station1,
|
||||
station2: c.station2,
|
||||
mode: c.mode,
|
||||
distance_km: c.distance_km,
|
||||
qso_timestamp: c.qso_timestamp
|
||||
}
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(&format_map_contact/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> dedup_map_reciprocals()
|
||||
end
|
||||
|
||||
defp format_map_contact(c) do
|
||||
lat1 = c.pos1["lat"]
|
||||
lon1 = c.pos1["lon"] || c.pos1["lng"]
|
||||
lat2 = c.pos2["lat"]
|
||||
lon2 = c.pos2["lon"] || c.pos2["lng"]
|
||||
|
||||
if lat1 && lon1 && lat2 && lon2 do
|
||||
[
|
||||
lat1,
|
||||
lon1,
|
||||
lat2,
|
||||
lon2,
|
||||
format_map_band(c.band),
|
||||
c.station1,
|
||||
c.station2,
|
||||
c.mode,
|
||||
format_map_distance(c.distance_km),
|
||||
format_map_timestamp(c.qso_timestamp),
|
||||
c.id
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
defp format_map_band(nil), do: 0
|
||||
defp format_map_band(band), do: Decimal.to_integer(band)
|
||||
|
||||
defp format_map_distance(nil), do: nil
|
||||
defp format_map_distance(d), do: Decimal.to_float(d)
|
||||
|
||||
defp format_map_timestamp(nil), do: nil
|
||||
defp format_map_timestamp(ts), do: Calendar.strftime(ts, "%Y-%m-%d %H:%M")
|
||||
|
||||
defp dedup_map_reciprocals(contacts) do
|
||||
Enum.uniq_by(contacts, fn [_lat1, _lon1, _lat2, _lon2, band, s1, s2, _mode, _dist, ts, _id] ->
|
||||
stations = Enum.sort([s1 || "", s2 || ""])
|
||||
hour = if ts, do: String.slice(ts, 0..12), else: ""
|
||||
{stations, band, hour}
|
||||
end)
|
||||
end
|
||||
|
||||
@spec list_contacts(keyword()) :: contact_page()
|
||||
def list_contacts(opts \\ []) do
|
||||
page = max(Keyword.get(opts, :page, 1), 1)
|
||||
|
|
@ -30,7 +124,7 @@ defmodule Microwaveprop.Radio do
|
|||
|
||||
base_query = maybe_search(Contact, search)
|
||||
|
||||
total_entries = Repo.aggregate(base_query, :count)
|
||||
total_entries = total_entries_for(search, base_query)
|
||||
total_pages = max(ceil(total_entries / @per_page), 1)
|
||||
|
||||
entries =
|
||||
|
|
@ -95,6 +189,18 @@ defmodule Microwaveprop.Radio do
|
|||
t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour])
|
||||
end
|
||||
|
||||
defp total_entries_for(search, query) when search in [nil, ""] do
|
||||
if Application.get_env(:microwaveprop, :cache_contact_count, true) do
|
||||
Cache.fetch_or_store(@count_cache_key, @count_cache_ttl_ms, fn ->
|
||||
Repo.aggregate(query, :count)
|
||||
end)
|
||||
else
|
||||
Repo.aggregate(query, :count)
|
||||
end
|
||||
end
|
||||
|
||||
defp total_entries_for(_search, query), do: Repo.aggregate(query, :count)
|
||||
|
||||
defp maybe_search(query, nil), do: query
|
||||
defp maybe_search(query, ""), do: query
|
||||
|
||||
|
|
@ -421,12 +527,20 @@ defmodule Microwaveprop.Radio do
|
|||
{{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} ->
|
||||
distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
|
||||
|
||||
changeset
|
||||
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|
||||
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|
||||
|> Ecto.Changeset.put_change(:distance_km, distance)
|
||||
|> Ecto.Changeset.put_change(:user_submitted, true)
|
||||
|> Repo.insert()
|
||||
result =
|
||||
changeset
|
||||
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|
||||
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|
||||
|> Ecto.Changeset.put_change(:distance_km, distance)
|
||||
|> Ecto.Changeset.put_change(:user_submitted, true)
|
||||
|> Repo.insert()
|
||||
|
||||
with {:ok, _} <- result do
|
||||
Cache.invalidate(@count_cache_key)
|
||||
Cache.invalidate(@map_payload_cache_key)
|
||||
end
|
||||
|
||||
result
|
||||
|
||||
_ ->
|
||||
changeset = Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates")
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ defmodule Microwaveprop.Radio.Contact do
|
|||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
@required_fields ~w(station1 station2 qso_timestamp mode band)a
|
||||
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id)a
|
||||
@required_fields ~w(station1 station2 qso_timestamp band)a
|
||||
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode)a
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(contact, attrs) do
|
||||
|
|
@ -60,7 +60,7 @@ defmodule Microwaveprop.Radio.Contact do
|
|||
end
|
||||
|
||||
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email)a
|
||||
@submission_required ~w(station1 station2 qso_timestamp mode 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_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1)
|
||||
|
||||
|
|
@ -74,6 +74,7 @@ defmodule Microwaveprop.Radio.Contact do
|
|||
|> sanitize_callsign(:station2)
|
||||
|> sanitize_grid(:grid1)
|
||||
|> sanitize_grid(:grid2)
|
||||
|> normalize_blank_mode()
|
||||
|> validate_callsign(:station1)
|
||||
|> validate_callsign(:station2)
|
||||
|> validate_grid_format(:grid1)
|
||||
|
|
@ -86,6 +87,22 @@ defmodule Microwaveprop.Radio.Contact do
|
|||
|> validate_length(:station2, max: 20)
|
||||
end
|
||||
|
||||
# Treat empty / whitespace-only mode strings as "not provided" so the column
|
||||
# ends up NULL instead of failing validate_inclusion. Mode is optional on the
|
||||
# submission path.
|
||||
defp normalize_blank_mode(changeset) do
|
||||
case get_change(changeset, :mode) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
value ->
|
||||
case String.trim(value) do
|
||||
"" -> put_change(changeset, :mode, nil)
|
||||
trimmed -> put_change(changeset, :mode, trimmed)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Either a user_id (logged-in submitter) or a submitter_email (anonymous)
|
||||
# must be present so we know who submitted the contact.
|
||||
defp validate_user_or_email(changeset) do
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
|
||||
@dedup_window_seconds 3600
|
||||
|
||||
@expected_columns 7
|
||||
@columns ~w(station1 station2 grid1 grid2 band mode qso_timestamp)
|
||||
@columns_with_mode ~w(station1 station2 grid1 grid2 band mode qso_timestamp)
|
||||
@columns_without_mode ~w(station1 station2 grid1 grid2 band qso_timestamp)
|
||||
|
||||
@doc """
|
||||
Parse-and-insert in one shot. Kept for backward compatibility — does NOT
|
||||
|
|
@ -156,19 +156,29 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
defp parse_row(line, row_num, submitter_email) do
|
||||
fields = parse_csv_fields(line)
|
||||
|
||||
if length(fields) == @expected_columns do
|
||||
attrs =
|
||||
@columns
|
||||
|> Enum.zip(fields)
|
||||
|> Map.new()
|
||||
|> Map.put("submitter_email", submitter_email)
|
||||
case columns_for(length(fields)) do
|
||||
nil ->
|
||||
{:invalid,
|
||||
%{
|
||||
row_num: row_num,
|
||||
messages: ["expected 6 columns (no mode) or 7 columns (with mode), got #{length(fields)}"]
|
||||
}}
|
||||
|
||||
parse_row_with_timestamp(attrs, row_num)
|
||||
else
|
||||
{:invalid, %{row_num: row_num, messages: ["expected #{@expected_columns} columns, got #{length(fields)}"]}}
|
||||
columns ->
|
||||
attrs =
|
||||
columns
|
||||
|> Enum.zip(fields)
|
||||
|> Map.new()
|
||||
|> Map.put("submitter_email", submitter_email)
|
||||
|
||||
parse_row_with_timestamp(attrs, row_num)
|
||||
end
|
||||
end
|
||||
|
||||
defp columns_for(7), do: @columns_with_mode
|
||||
defp columns_for(6), do: @columns_without_mode
|
||||
defp columns_for(_), do: nil
|
||||
|
||||
defp parse_row_with_timestamp(attrs, row_num) do
|
||||
case normalize_timestamp(attrs["qso_timestamp"]) do
|
||||
{:ok, iso_timestamp} ->
|
||||
|
|
|
|||
|
|
@ -235,7 +235,9 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<span :if={@label} class="label mb-1">
|
||||
{@label}<span :if={@rest[:required]} class="text-error ml-0.5" aria-hidden="true">*</span>
|
||||
</span>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
|
|
@ -256,7 +258,9 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<span :if={@label} class="label mb-1">
|
||||
{@label}<span :if={@rest[:required]} class="text-error ml-0.5" aria-hidden="true">*</span>
|
||||
</span>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
|
|
@ -277,7 +281,9 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<span :if={@label} class="label mb-1">
|
||||
{@label}<span :if={@rest[:required]} class="text-error ml-0.5" aria-hidden="true">*</span>
|
||||
</span>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
@moduledoc false
|
||||
use MicrowavepropWeb, :live_view
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.Radio
|
||||
|
||||
@band_colors %{
|
||||
1296 => "#475569",
|
||||
|
|
@ -24,23 +21,15 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
contacts = load_contacts()
|
||||
|
||||
bands =
|
||||
contacts
|
||||
|> Enum.map(fn [_, _, _, _, band | _] -> band end)
|
||||
|> Enum.uniq()
|
||||
|> Enum.sort()
|
||||
|
||||
enabled_bands = MapSet.new(bands)
|
||||
%{json: json, count: count, bands: bands} = Radio.contact_map_payload()
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
page_title: "Contact Map",
|
||||
contacts_json: Jason.encode!(contacts),
|
||||
contact_count: length(contacts),
|
||||
contacts_json: IO.iodata_to_binary(json),
|
||||
contact_count: count,
|
||||
all_bands: bands,
|
||||
enabled_bands: enabled_bands,
|
||||
enabled_bands: MapSet.new(bands),
|
||||
callsign_filter: ""
|
||||
)}
|
||||
end
|
||||
|
|
@ -92,67 +81,6 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
|||
|
||||
defp band_color(mhz), do: Map.get(@band_colors, mhz, "#64748b")
|
||||
|
||||
defp load_contacts do
|
||||
from(c in Contact,
|
||||
where: not is_nil(c.pos1) and not is_nil(c.pos2),
|
||||
select: %{
|
||||
id: c.id,
|
||||
pos1: c.pos1,
|
||||
pos2: c.pos2,
|
||||
band: c.band,
|
||||
station1: c.station1,
|
||||
station2: c.station2,
|
||||
mode: c.mode,
|
||||
distance_km: c.distance_km,
|
||||
qso_timestamp: c.qso_timestamp
|
||||
}
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(&format_contact/1)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> dedup_reciprocals()
|
||||
end
|
||||
|
||||
defp format_contact(c) do
|
||||
lat1 = c.pos1["lat"]
|
||||
lon1 = c.pos1["lon"] || c.pos1["lng"]
|
||||
lat2 = c.pos2["lat"]
|
||||
lon2 = c.pos2["lon"] || c.pos2["lng"]
|
||||
|
||||
if lat1 && lon1 && lat2 && lon2 do
|
||||
[
|
||||
lat1,
|
||||
lon1,
|
||||
lat2,
|
||||
lon2,
|
||||
format_band(c.band),
|
||||
c.station1,
|
||||
c.station2,
|
||||
c.mode,
|
||||
format_distance(c.distance_km),
|
||||
format_timestamp(c.qso_timestamp),
|
||||
c.id
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
defp format_band(nil), do: 0
|
||||
defp format_band(band), do: Decimal.to_integer(band)
|
||||
|
||||
defp format_distance(nil), do: nil
|
||||
defp format_distance(d), do: Decimal.to_float(d)
|
||||
|
||||
defp format_timestamp(nil), do: nil
|
||||
defp format_timestamp(ts), do: Calendar.strftime(ts, "%Y-%m-%d %H:%M")
|
||||
|
||||
# Deduplicate reciprocal contacts (A↔B same band/hour = same contact logged by both sides)
|
||||
defp dedup_reciprocals(contacts) do
|
||||
Enum.uniq_by(contacts, fn [_lat1, _lon1, _lat2, _lon2, band, s1, s2, _mode, _dist, ts, _id] ->
|
||||
stations = Enum.sort([s1 || "", s2 || ""])
|
||||
hour = if ts, do: String.slice(ts, 0..12), else: ""
|
||||
{stations, band, hour}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
|
|
|
|||
|
|
@ -362,9 +362,8 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
field={@form[:mode]}
|
||||
type="select"
|
||||
label="Mode"
|
||||
prompt="Select mode"
|
||||
prompt="Optional"
|
||||
options={@mode_options}
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@form[:qso_timestamp]}
|
||||
|
|
@ -405,9 +404,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
<div class="space-y-6">
|
||||
<div class="alert text-sm">
|
||||
<div>
|
||||
<p class="mb-2">
|
||||
Upload a CSV file with multiple contacts. Required 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>
|
||||
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
|
||||
<li>
|
||||
|
|
@ -417,6 +414,10 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
defmodule Microwaveprop.Repo.Migrations.AddContactsInsertedAtDescIndex do
|
||||
use Ecto.Migration
|
||||
|
||||
# Matches the default sort for `Radio.list_contacts/1` which is
|
||||
# `ORDER BY inserted_at DESC, id DESC`. Without this index Postgres does a
|
||||
# Seq Scan + top-N heapsort over every row on each `/contacts` page load
|
||||
# (measured ~77ms on 58k rows).
|
||||
def change do
|
||||
create index(:contacts, ["inserted_at DESC", "id DESC"],
|
||||
name: :contacts_inserted_at_desc_id_desc_idx
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
defmodule Microwaveprop.Repo.Migrations.MakeContactsModeNullable do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:contacts) do
|
||||
modify :mode, :string, null: true, from: {:string, null: false}
|
||||
end
|
||||
end
|
||||
end
|
||||
46
test/microwaveprop/cache_test.exs
Normal file
46
test/microwaveprop/cache_test.exs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Microwaveprop.CacheTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Microwaveprop.Cache
|
||||
|
||||
setup do
|
||||
Cache.clear()
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "fetch_or_store/3" do
|
||||
test "invokes the function and returns the value on a cold cache" do
|
||||
assert Cache.fetch_or_store(:the_key, 1_000, fn -> 42 end) == 42
|
||||
end
|
||||
|
||||
test "returns the cached value without invoking the function on a warm hit" do
|
||||
Cache.fetch_or_store(:the_key, 1_000, fn -> 42 end)
|
||||
assert Cache.fetch_or_store(:the_key, 1_000, fn -> raise "should not run" end) == 42
|
||||
end
|
||||
|
||||
test "re-invokes the function once the TTL expires" do
|
||||
Cache.put(:the_key, 1, -1)
|
||||
assert Cache.fetch_or_store(:the_key, 1_000, fn -> 2 end) == 2
|
||||
end
|
||||
|
||||
test "isolates values by key" do
|
||||
assert Cache.fetch_or_store(:a, 1_000, fn -> 1 end) == 1
|
||||
assert Cache.fetch_or_store(:b, 1_000, fn -> 2 end) == 2
|
||||
assert Cache.fetch_or_store(:a, 1_000, fn -> raise "no" end) == 1
|
||||
assert Cache.fetch_or_store(:b, 1_000, fn -> raise "no" end) == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "put/3 and invalidate/1" do
|
||||
test "put/3 stores a value with the given TTL" do
|
||||
Cache.put(:k, "hello", 1_000)
|
||||
assert Cache.fetch_or_store(:k, 1_000, fn -> "other" end) == "hello"
|
||||
end
|
||||
|
||||
test "invalidate/1 removes an entry, forcing the next fetch to recompute" do
|
||||
Cache.put(:k, "stale", 60_000)
|
||||
Cache.invalidate(:k)
|
||||
assert Cache.fetch_or_store(:k, 1_000, fn -> "fresh" end) == "fresh"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -20,18 +20,29 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
|
|||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "requires station1, station2, qso_timestamp, mode, band, grid1, grid2, submitter_email" do
|
||||
test "requires station1, station2, qso_timestamp, band, grid1, grid2, submitter_email" do
|
||||
changeset = Contact.submission_changeset(%Contact{}, %{})
|
||||
errors = errors_on(changeset)
|
||||
|
||||
assert errors[:station1]
|
||||
assert errors[:station2]
|
||||
assert errors[:qso_timestamp]
|
||||
assert errors[:mode]
|
||||
assert errors[:band]
|
||||
assert errors[:grid1]
|
||||
assert errors[:grid2]
|
||||
assert errors[:submitter_email]
|
||||
|
||||
# Mode is optional on submission
|
||||
refute errors[:mode]
|
||||
end
|
||||
|
||||
test "accepts a blank mode and normalises it to nil" do
|
||||
for blank <- ["", " ", nil] do
|
||||
attrs = Map.put(@valid_attrs, :mode, blank)
|
||||
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||
assert changeset.valid?, "Expected blank mode #{inspect(blank)} to validate"
|
||||
assert Ecto.Changeset.get_field(changeset, :mode) == nil
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects invalid grid1" do
|
||||
|
|
|
|||
|
|
@ -22,16 +22,15 @@ defmodule Microwaveprop.Radio.ContactTest do
|
|||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "requires station1, station2, qso_timestamp, mode, and band" do
|
||||
test "requires station1, station2, qso_timestamp, and band (mode is optional)" do
|
||||
changeset = Contact.changeset(%Contact{}, %{})
|
||||
|
||||
assert %{
|
||||
station1: ["can't be blank"],
|
||||
station2: ["can't be blank"],
|
||||
qso_timestamp: ["can't be blank"],
|
||||
mode: ["can't be blank"],
|
||||
band: ["can't be blank"]
|
||||
} = errors_on(changeset)
|
||||
errors = errors_on(changeset)
|
||||
assert errors.station1 == ["can't be blank"]
|
||||
assert errors.station2 == ["can't be blank"]
|
||||
assert errors.qso_timestamp == ["can't be blank"]
|
||||
assert errors.band == ["can't be blank"]
|
||||
refute Map.has_key?(errors, :mode)
|
||||
end
|
||||
|
||||
test "persists to the database" do
|
||||
|
|
|
|||
|
|
@ -122,6 +122,25 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
|||
assert {:ok, _result} = CsvImport.import(csv, @submitter)
|
||||
assert Repo.aggregate(Contact, :count) == 1
|
||||
end
|
||||
|
||||
test "accepts a 6-column CSV without the mode column" do
|
||||
csv =
|
||||
"station1,station2,grid1,grid2,band,qso_timestamp\n" <>
|
||||
"W5XD,K5TR,EM12,EM00,10000,2026-03-28T18:00:00Z"
|
||||
|
||||
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
||||
assert contact.station1 == "W5XD"
|
||||
assert contact.mode == nil
|
||||
end
|
||||
|
||||
test "accepts a 7-column CSV with a blank mode value" do
|
||||
csv =
|
||||
"station1,station2,grid1,grid2,band,mode,qso_timestamp\n" <>
|
||||
"W5XD,K5TR,EM12,EM00,10000,,2026-03-28T18:00:00Z"
|
||||
|
||||
assert {:ok, %{imported: [contact], errors: []}} = CsvImport.import(csv, @submitter)
|
||||
assert contact.mode == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "import/2 with invalid data" do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue