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[]) {
|
renderScores(this: PropagationMapHook, scores: ScorePoint[]) {
|
||||||
this.scores = scores
|
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) {
|
if (!this.scoreGrid) {
|
||||||
this.scoreGrid = new 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))
|
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)
|
const colorCache = new Array<string>(101)
|
||||||
for (let i = 0; i <= 100; i++) {
|
for (let i = 0; i <= 100; i++) {
|
||||||
const c = this.scoreColorRGB(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, solar_req_options: [plug: {Req.Test, Microwaveprop.Weather.SolarClient}]
|
||||||
config :microwaveprop, srtm_req_options: [plug: {Req.Test, Microwaveprop.Terrain.Srtm}, retry: false]
|
config :microwaveprop, srtm_req_options: [plug: {Req.Test, Microwaveprop.Terrain.Srtm}, retry: false]
|
||||||
config :microwaveprop, start_freshness_monitor: 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
|
# Initialize plugs at runtime for faster test compilation
|
||||||
config :phoenix, :plug_init_mode, :runtime
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule Microwaveprop.Application do
|
||||||
Microwaveprop.Repo,
|
Microwaveprop.Repo,
|
||||||
{Cluster.Supervisor, [topologies, [name: Microwaveprop.ClusterSupervisor]]},
|
{Cluster.Supervisor, [topologies, [name: Microwaveprop.ClusterSupervisor]]},
|
||||||
{Phoenix.PubSub, name: Microwaveprop.PubSub},
|
{Phoenix.PubSub, name: Microwaveprop.PubSub},
|
||||||
|
Microwaveprop.Cache,
|
||||||
Microwaveprop.Propagation.ScoreCache,
|
Microwaveprop.Propagation.ScoreCache,
|
||||||
Microwaveprop.Weather.NexradCache,
|
Microwaveprop.Weather.NexradCache,
|
||||||
{Oban, Application.fetch_env!(:microwaveprop, Oban)},
|
{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
|
import Ecto.Query
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.Cache
|
||||||
alias Microwaveprop.Radio.Contact
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Radio.ContactEdit
|
alias Microwaveprop.Radio.ContactEdit
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
|
|
@ -11,6 +12,10 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
@per_page 20
|
@per_page 20
|
||||||
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a
|
@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 :: %{
|
@type contact_page :: %{
|
||||||
entries: [Contact.t()],
|
entries: [Contact.t()],
|
||||||
|
|
@ -20,6 +25,95 @@ defmodule Microwaveprop.Radio do
|
||||||
total_entries: non_neg_integer()
|
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()
|
@spec list_contacts(keyword()) :: contact_page()
|
||||||
def list_contacts(opts \\ []) do
|
def list_contacts(opts \\ []) do
|
||||||
page = max(Keyword.get(opts, :page, 1), 1)
|
page = max(Keyword.get(opts, :page, 1), 1)
|
||||||
|
|
@ -30,7 +124,7 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
base_query = maybe_search(Contact, search)
|
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)
|
total_pages = max(ceil(total_entries / @per_page), 1)
|
||||||
|
|
||||||
entries =
|
entries =
|
||||||
|
|
@ -95,6 +189,18 @@ defmodule Microwaveprop.Radio do
|
||||||
t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour])
|
t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour])
|
||||||
end
|
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, nil), do: query
|
||||||
defp maybe_search(query, ""), do: query
|
defp maybe_search(query, ""), do: query
|
||||||
|
|
||||||
|
|
@ -421,12 +527,20 @@ defmodule Microwaveprop.Radio do
|
||||||
{{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} ->
|
{{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} ->
|
||||||
distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
|
distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
|
||||||
|
|
||||||
changeset
|
result =
|
||||||
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|
changeset
|
||||||
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|
||||||
|> Ecto.Changeset.put_change(:distance_km, distance)
|
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|
||||||
|> Ecto.Changeset.put_change(:user_submitted, true)
|
|> Ecto.Changeset.put_change(:distance_km, distance)
|
||||||
|> Repo.insert()
|
|> 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")
|
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__{}
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
@required_fields ~w(station1 station2 qso_timestamp mode band)a
|
@required_fields ~w(station1 station2 qso_timestamp band)a
|
||||||
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id)a
|
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode)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
|
||||||
|
|
@ -60,7 +60,7 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
end
|
end
|
||||||
|
|
||||||
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email)a
|
@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_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)
|
@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_callsign(:station2)
|
||||||
|> sanitize_grid(:grid1)
|
|> sanitize_grid(:grid1)
|
||||||
|> sanitize_grid(:grid2)
|
|> sanitize_grid(:grid2)
|
||||||
|
|> normalize_blank_mode()
|
||||||
|> validate_callsign(:station1)
|
|> validate_callsign(:station1)
|
||||||
|> validate_callsign(:station2)
|
|> validate_callsign(:station2)
|
||||||
|> validate_grid_format(:grid1)
|
|> validate_grid_format(:grid1)
|
||||||
|
|
@ -86,6 +87,22 @@ defmodule Microwaveprop.Radio.Contact do
|
||||||
|> validate_length(:station2, max: 20)
|
|> validate_length(:station2, max: 20)
|
||||||
end
|
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)
|
# Either a user_id (logged-in submitter) or a submitter_email (anonymous)
|
||||||
# must be present so we know who submitted the contact.
|
# must be present so we know who submitted the contact.
|
||||||
defp validate_user_or_email(changeset) do
|
defp validate_user_or_email(changeset) do
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ defmodule Microwaveprop.Radio.CsvImport do
|
||||||
|
|
||||||
@dedup_window_seconds 3600
|
@dedup_window_seconds 3600
|
||||||
|
|
||||||
@expected_columns 7
|
@columns_with_mode ~w(station1 station2 grid1 grid2 band mode qso_timestamp)
|
||||||
@columns ~w(station1 station2 grid1 grid2 band mode qso_timestamp)
|
@columns_without_mode ~w(station1 station2 grid1 grid2 band qso_timestamp)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Parse-and-insert in one shot. Kept for backward compatibility — does NOT
|
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
|
defp parse_row(line, row_num, submitter_email) do
|
||||||
fields = parse_csv_fields(line)
|
fields = parse_csv_fields(line)
|
||||||
|
|
||||||
if length(fields) == @expected_columns do
|
case columns_for(length(fields)) do
|
||||||
attrs =
|
nil ->
|
||||||
@columns
|
{:invalid,
|
||||||
|> Enum.zip(fields)
|
%{
|
||||||
|> Map.new()
|
row_num: row_num,
|
||||||
|> Map.put("submitter_email", submitter_email)
|
messages: ["expected 6 columns (no mode) or 7 columns (with mode), got #{length(fields)}"]
|
||||||
|
}}
|
||||||
|
|
||||||
parse_row_with_timestamp(attrs, row_num)
|
columns ->
|
||||||
else
|
attrs =
|
||||||
{:invalid, %{row_num: row_num, messages: ["expected #{@expected_columns} columns, got #{length(fields)}"]}}
|
columns
|
||||||
|
|> Enum.zip(fields)
|
||||||
|
|> Map.new()
|
||||||
|
|> Map.put("submitter_email", submitter_email)
|
||||||
|
|
||||||
|
parse_row_with_timestamp(attrs, row_num)
|
||||||
end
|
end
|
||||||
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
|
defp parse_row_with_timestamp(attrs, row_num) do
|
||||||
case normalize_timestamp(attrs["qso_timestamp"]) do
|
case normalize_timestamp(attrs["qso_timestamp"]) do
|
||||||
{:ok, iso_timestamp} ->
|
{:ok, iso_timestamp} ->
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,9 @@ defmodule MicrowavepropWeb.CoreComponents do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="fieldset mb-2">
|
<div class="fieldset mb-2">
|
||||||
<label>
|
<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
|
<select
|
||||||
id={@id}
|
id={@id}
|
||||||
name={@name}
|
name={@name}
|
||||||
|
|
@ -256,7 +258,9 @@ defmodule MicrowavepropWeb.CoreComponents do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="fieldset mb-2">
|
<div class="fieldset mb-2">
|
||||||
<label>
|
<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
|
<textarea
|
||||||
id={@id}
|
id={@id}
|
||||||
name={@name}
|
name={@name}
|
||||||
|
|
@ -277,7 +281,9 @@ defmodule MicrowavepropWeb.CoreComponents do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="fieldset mb-2">
|
<div class="fieldset mb-2">
|
||||||
<label>
|
<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
|
<input
|
||||||
type={@type}
|
type={@type}
|
||||||
name={@name}
|
name={@name}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
import Ecto.Query
|
alias Microwaveprop.Radio
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Contact
|
|
||||||
alias Microwaveprop.Repo
|
|
||||||
|
|
||||||
@band_colors %{
|
@band_colors %{
|
||||||
1296 => "#475569",
|
1296 => "#475569",
|
||||||
|
|
@ -24,23 +21,15 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
contacts = load_contacts()
|
%{json: json, count: count, bands: bands} = Radio.contact_map_payload()
|
||||||
|
|
||||||
bands =
|
|
||||||
contacts
|
|
||||||
|> Enum.map(fn [_, _, _, _, band | _] -> band end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
|> Enum.sort()
|
|
||||||
|
|
||||||
enabled_bands = MapSet.new(bands)
|
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
page_title: "Contact Map",
|
page_title: "Contact Map",
|
||||||
contacts_json: Jason.encode!(contacts),
|
contacts_json: IO.iodata_to_binary(json),
|
||||||
contact_count: length(contacts),
|
contact_count: count,
|
||||||
all_bands: bands,
|
all_bands: bands,
|
||||||
enabled_bands: enabled_bands,
|
enabled_bands: MapSet.new(bands),
|
||||||
callsign_filter: ""
|
callsign_filter: ""
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
@ -92,67 +81,6 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
|
|
||||||
defp band_color(mhz), do: Map.get(@band_colors, mhz, "#64748b")
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
|
|
||||||
|
|
@ -362,9 +362,8 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
field={@form[:mode]}
|
field={@form[:mode]}
|
||||||
type="select"
|
type="select"
|
||||||
label="Mode"
|
label="Mode"
|
||||||
prompt="Select mode"
|
prompt="Optional"
|
||||||
options={@mode_options}
|
options={@mode_options}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:qso_timestamp]}
|
field={@form[:qso_timestamp]}
|
||||||
|
|
@ -405,9 +404,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="alert text-sm">
|
<div class="alert text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-2">
|
<p class="mb-2">Upload a CSV file with multiple contacts. Columns:</p>
|
||||||
Upload a CSV file with multiple contacts. Required 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</code>
|
||||||
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
|
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -417,6 +414,10 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
|
Grid squares should be as detailed as possible (8 characters preferred, e.g. EM12kp37)
|
||||||
</li>
|
</li>
|
||||||
<li>Band in MHz (e.g. 10000, 24000)</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>
|
</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,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?
|
assert changeset.valid?
|
||||||
end
|
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{}, %{})
|
changeset = Contact.submission_changeset(%Contact{}, %{})
|
||||||
errors = errors_on(changeset)
|
errors = errors_on(changeset)
|
||||||
|
|
||||||
assert errors[:station1]
|
assert errors[:station1]
|
||||||
assert errors[:station2]
|
assert errors[:station2]
|
||||||
assert errors[:qso_timestamp]
|
assert errors[:qso_timestamp]
|
||||||
assert errors[:mode]
|
|
||||||
assert errors[:band]
|
assert errors[:band]
|
||||||
assert errors[:grid1]
|
assert errors[:grid1]
|
||||||
assert errors[:grid2]
|
assert errors[:grid2]
|
||||||
assert errors[:submitter_email]
|
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
|
end
|
||||||
|
|
||||||
test "rejects invalid grid1" do
|
test "rejects invalid grid1" do
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,15 @@ defmodule Microwaveprop.Radio.ContactTest do
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
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{}, %{})
|
changeset = Contact.changeset(%Contact{}, %{})
|
||||||
|
|
||||||
assert %{
|
errors = errors_on(changeset)
|
||||||
station1: ["can't be blank"],
|
assert errors.station1 == ["can't be blank"]
|
||||||
station2: ["can't be blank"],
|
assert errors.station2 == ["can't be blank"]
|
||||||
qso_timestamp: ["can't be blank"],
|
assert errors.qso_timestamp == ["can't be blank"]
|
||||||
mode: ["can't be blank"],
|
assert errors.band == ["can't be blank"]
|
||||||
band: ["can't be blank"]
|
refute Map.has_key?(errors, :mode)
|
||||||
} = errors_on(changeset)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "persists to the database" do
|
test "persists to the database" do
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,25 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
||||||
assert {:ok, _result} = CsvImport.import(csv, @submitter)
|
assert {:ok, _result} = CsvImport.import(csv, @submitter)
|
||||||
assert Repo.aggregate(Contact, :count) == 1
|
assert Repo.aggregate(Contact, :count) == 1
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "import/2 with invalid data" do
|
describe "import/2 with invalid data" do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue