Rename QSO to Contact in UI, add higher bands, improve submit page

- All menus and UI text now say Contacts instead of QSOs
- Add 68, 122, 134, 241 GHz bands to submit form and validation
- Add info box on submit page explaining why contacts matter
- Larger submit button with icon
- Make HRRR partition migration idempotent for partial re-runs
This commit is contained in:
Graham McIntire 2026-04-01 12:28:24 -05:00
parent 2c16fd979d
commit ea96c93e2d
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 116 additions and 26 deletions

View file

@ -30,6 +30,7 @@ import {hooks as colocatedHooks} from "phoenix-colocated/microwaveprop"
import topbar from "../vendor/topbar"
import {PropagationMap} from "./propagation_map_hook"
import {ContactMap} from "./contact_map_hook"
const UtcClock = {
mounted() {
@ -51,7 +52,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, PropagationMap, UtcClock},
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap},
})
// Show progress bar on live navigation and form submits

View file

@ -0,0 +1,41 @@
export const ContactMap = {
mounted() {
const pos1 = JSON.parse(this.el.dataset.pos1)
const pos2 = JSON.parse(this.el.dataset.pos2)
const lat1 = pos1.lat
const lon1 = pos1.lon || pos1.lng
const lat2 = pos2.lat
const lon2 = pos2.lon || pos2.lng
const map = L.map(this.el, {
zoomControl: true,
attributionControl: false,
scrollWheelZoom: false
})
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(map)
const marker1 = L.circleMarker([lat1, lon1], {
radius: 6, color: "#2563eb", fillColor: "#3b82f6", fillOpacity: 0.9, weight: 2
}).addTo(map)
const marker2 = L.circleMarker([lat2, lon2], {
radius: 6, color: "#dc2626", fillColor: "#ef4444", fillOpacity: 0.9, weight: 2
}).addTo(map)
const station1 = this.el.dataset.station1
const station2 = this.el.dataset.station2
if (station1) marker1.bindTooltip(station1, {permanent: true, direction: "top", offset: [0, -8]})
if (station2) marker2.bindTooltip(station2, {permanent: true, direction: "top", offset: [0, -8]})
L.polyline([[lat1, lon1], [lat2, lon2]], {
color: "#6366f1", weight: 2, dashArray: "6 4", opacity: 0.8
}).addTo(map)
const bounds = L.latLngBounds([[lat1, lon1], [lat2, lon2]])
map.fitBounds(bounds, {padding: [40, 40], maxZoom: 10})
}
}

View file

@ -41,7 +41,7 @@ defmodule Microwaveprop.Radio.Contact do
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email)a
@allowed_modes ~w(CW SSB FM FT8 FT4)
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 75000), &Decimal.new/1)
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1)
def submission_changeset(contact, attrs) do
contact

View file

@ -41,7 +41,7 @@ defmodule MicrowavepropWeb.Layouts do
<a href="/" class="font-semibold">Microwaveprop</a>
<nav class="flex gap-2">
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
<.link navigate="/qsos" class="btn btn-ghost btn-sm">QSOs</.link>
<.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts</.link>
<.link navigate="/submit" class="btn btn-ghost btn-sm">Submit</.link>
</nav>
</div>

View file

@ -30,12 +30,12 @@ defmodule MicrowavepropWeb.ContactLive.Index do
{:noreply,
assign(socket,
page_title: "QSOs",
page_title: "Contacts",
page: result.page,
total_pages: result.total_pages,
total_entries: result.total_entries,
qsos: result.entries,
grouped_qsos: result.grouped_entries,
contacts: result.entries,
grouped_contacts: result.grouped_entries,
sort_by: sort_by,
sort_order: sort_order,
search: search
@ -76,7 +76,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
<:subtitle>{@total_entries} contacts</:subtitle>
<:actions>
<.link navigate={~p"/submit"} class="btn btn-primary">
<.icon name="hero-plus" class="w-5 h-5" /> Submit QSO
<.icon name="hero-plus" class="w-5 h-5" /> Submit Contact
</.link>
</:actions>
</.header>
@ -111,7 +111,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
</tr>
</thead>
<tbody>
<%= for {primary, reciprocals} <- @grouped_qsos do %>
<%= for {primary, reciprocals} <- @grouped_contacts do %>
<tr class="h-1">
<td colspan="10"></td>
</tr>
@ -126,7 +126,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
<span
:if={reciprocals != []}
class="badge badge-primary badge-xs"
title="Has reciprocal QSO"
title="Has reciprocal contact"
>
{length(reciprocals) + 1}
</span>

View file

@ -145,14 +145,28 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<:subtitle>{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
<:actions>
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to QSOs
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
</.link>
</:actions>
</.header>
<%= if @contact.pos1 && @contact.pos2 do %>
<p class="text-xs text-base-content/50 italic mb-1">Locations approximate, in the center of the grid squares</p>
<div
id="contact-map"
phx-hook="ContactMap"
phx-update="ignore"
data-pos1={Jason.encode!(@contact.pos1)}
data-pos2={Jason.encode!(@contact.pos2)}
data-station1={@contact.station1}
data-station2={@contact.station2}
class="w-full h-64 rounded-lg border border-base-300 mb-4"
/>
<% end %>
<div class="divider" />
<h2 class="text-base font-semibold mb-2">QSO Details</h2>
<h2 class="text-base font-semibold mb-2">Contact Details</h2>
<.list>
<:item title="Station 1">{@contact.station1}</:item>
<:item title="Station 2">{@contact.station2}</:item>

View file

@ -337,10 +337,10 @@ defmodule MicrowavepropWeb.MapLive do
<.icon name="hero-calculator" class="size-3.5" /> Scoring Algorithm
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a QSO
<.icon name="hero-arrow-up-tray" class="size-3.5" /> Submit a Contact
</.link>
<.link navigate="/qsos" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-signal" class="size-3.5" /> QSO Training Data
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start gap-1.5">
<.icon name="hero-signal" class="size-3.5" /> Contact Training Data
</.link>
</div>
</div>

View file

@ -14,7 +14,11 @@ defmodule MicrowavepropWeb.SubmitLive do
{"10 GHz", "10000"},
{"24 GHz", "24000"},
{"47 GHz", "47000"},
{"76 GHz", "75000"}
{"68 GHz", "68000"},
{"76 GHz", "75000"},
{"122 GHz", "122000"},
{"134 GHz", "134000"},
{"241 GHz", "241000"}
]
@mode_options ~w(CW SSB FM FT8 FT4)
@ -80,9 +84,19 @@ defmodule MicrowavepropWeb.SubmitLive do
<Layouts.app flash={@flash}>
<.header>
Submit Contact
<:subtitle>Submit a microwave contact for propagation analysis</:subtitle>
<:subtitle>Help us build a better propagation model</:subtitle>
</.header>
<div class="bg-info/10 border border-info/30 rounded-box p-4 mb-6 text-sm leading-relaxed">
<p class="font-semibold text-info mb-1">Every contact matters</p>
<p>
Our propagation model improves with real-world data. Each verified microwave contact
you submit is matched against atmospheric conditions at the time of your contact,
helping us calibrate predictions for all bands from 10 GHz to 241 GHz. The more contacts
we have, the better our forecasts get for everyone.
</p>
</div>
<.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" />
@ -133,7 +147,9 @@ defmodule MicrowavepropWeb.SubmitLive do
/>
<div class="mt-6">
<.button phx-disable-with="Submitting..." class="btn-primary">Submit Contact</.button>
<.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>
</Layouts.app>

View file

@ -5,6 +5,28 @@ defmodule Microwaveprop.Repo.Migrations.PartitionHrrrProfiles do
@disable_migration_lock true
def up do
# Clean up any partial previous run: drop the partitioned table and orphaned
# partition tables, then rename _old back so we can start fresh.
execute """
DO $$
DECLARE
tbl text;
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'hrrr_profiles_old' AND schemaname = 'public') THEN
DROP TABLE IF EXISTS hrrr_profiles CASCADE;
-- Drop orphaned partition tables left behind after CASCADE
FOR tbl IN
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename LIKE 'hrrr_profiles_%'
AND tablename != 'hrrr_profiles_old'
LOOP
EXECUTE 'DROP TABLE IF EXISTS ' || tbl || ' CASCADE';
END LOOP;
ALTER TABLE hrrr_profiles_old RENAME TO hrrr_profiles;
END IF;
END $$
"""
# 1. Rename old table
execute "ALTER TABLE hrrr_profiles RENAME TO hrrr_profiles_old"
@ -31,7 +53,7 @@ defmodule Microwaveprop.Repo.Migrations.PartitionHrrrProfiles do
) PARTITION BY RANGE (valid_time)
"""
# 3. Create partitions (quarterly for historical, monthly for recent/future)
# 3. Create partitions
partitions = [
{"2016-01-01", "2017-01-01"},
{"2017-01-01", "2018-01-01"},
@ -74,18 +96,15 @@ defmodule Microwaveprop.Repo.Migrations.PartitionHrrrProfiles do
"""
end
# 4. Drop old indexes (they reference the renamed table) and create new ones
# 4. Indexes on partitioned table
execute "DROP INDEX IF EXISTS hrrr_profiles_lat_lon_valid_time_index"
execute "DROP INDEX IF EXISTS hrrr_profiles_valid_time_index"
execute "CREATE UNIQUE INDEX hrrr_profiles_lat_lon_valid_time_index ON hrrr_profiles (lat, lon, valid_time)"
execute "CREATE INDEX hrrr_profiles_valid_time_index ON hrrr_profiles (valid_time)"
# 5. Migrate data from old table in batches to avoid long locks
execute """
INSERT INTO hrrr_profiles SELECT * FROM hrrr_profiles_old
"""
# 5. Migrate data
execute "INSERT INTO hrrr_profiles SELECT * FROM hrrr_profiles_old"
# 6. Drop old table
execute "DROP TABLE hrrr_profiles_old"
@ -120,7 +139,6 @@ defmodule Microwaveprop.Repo.Migrations.PartitionHrrrProfiles do
execute "ALTER TABLE hrrr_profiles_flat RENAME TO hrrr_profiles"
execute "CREATE UNIQUE INDEX hrrr_profiles_lat_lon_valid_time_index ON hrrr_profiles (lat, lon, valid_time)"
execute "CREATE INDEX hrrr_profiles_valid_time_index ON hrrr_profiles (valid_time)"
end
end

View file

@ -43,7 +43,7 @@ defmodule MicrowavepropWeb.MapLiveTest do
test "renders submit QSO link", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "Submit a QSO"
assert html =~ "Submit a Contact"
assert html =~ ~s(/submit)
end
end