Commit graph

806 commits

Author SHA1 Message Date
ece721e490
Add 90-min timeout kill to PropagationGridWorker 2026-04-13 09:46:57 -05:00
6274d03543
Merge remote-tracking branch 'origin/main' 2026-04-13 09:42:01 -05:00
87119c33e1
Refresh viewshed on band change when a point is selected 2026-04-13 09:41:58 -05:00
FluxCD
2b6d53a17e chore: update prop image to git.mcintire.me/graham/prop:main-1776089716-abd3512 [skip ci] 2026-04-13 14:19:07 +00:00
abd35127c7
Merge remote-tracking branch 'origin/main' 2026-04-13 09:14:46 -05:00
8ea0c3b94a
Ingest Canadian radiosondes via UWYO + plans for RDPS/HRDPS
IEM's RAOB endpoint stopped keeping Canadian stations current — most
stalled around Sep 2024 — which leaves a gap in refractivity/ducting
calibration over all the CW*/CY* stations already in weather_stations.
MSC's own tephi CSV is fixed-width and rendering-focused; the WMO TEMP
bulletins would need a full decoder. University of Wyoming serves the
same stations in a clean space-delimited format, is current, and
accepts the 3-letter station code (drop the leading C from Canadian
ICAO). Its output shape matches IemClient.parse_raob_json/1 exactly,
so it drops straight into Weather.upsert_sounding/2.

- New UwyoSoundingClient with URL builder, Req-based fetch_sounding,
  and a fixed-width parse_sounding_html that extracts PRES/HGHT/TEMP/
  DWPT/DRCT/SKNT from the <PRE> block and DateTime from the <H2> header.
- New CanadianSoundingFetchWorker: Oban batch worker that finds all
  sounding stations whose code starts with C, picks the most recently
  publishable 00Z or 12Z slot (90 min publish delay), calls UWYO per
  station, and upserts with SoundingParams-derived refractivity/
  ducting fields.
- Oban cron at 01:30Z and 13:30Z (dev + prod) to drive the worker.
- Req.Test plug wiring in config/test.exs.
- Captured Goose Bay fixture as test/support/fixtures/uwyo_sounding_yyr.html.

Also drops two implementation plans under docs/plans/:
- 2026-04-13-rdps-vertical-profiles.md: ~2 day plan for RDPS 10 km
  Canadian ducting outside HRRR's Lambert footprint.
- 2026-04-13-hrdps-canadian-prop-grid.md: ~5 day plan for the full
  HRDPS 2.5 km Canadian prop grid. Explicitly recommends doing RDPS
  first.

TDD throughout, 19 new tests, 1318/1318 passing, credo strict clean.
2026-04-13 09:14:34 -05:00
FluxCD
38bb3a3852 chore: update prop image to git.mcintire.me/graham/prop:main-1776088074-021ddcf [skip ci] 2026-04-13 13:49:00 +00:00
021ddcfdd2
Merge remote-tracking branch 'origin/main' 2026-04-13 08:47:32 -05:00
1ef8bd6641
Reset daisyUI .select styling on sutra_ui wrapper divs
daisyUI 5 defines `.select` with its own border, chevron background-image,
padding, width clamp and box-shadow. Those rules were bleeding through
onto sutra_ui's wrapper `<div class="select">` in the live_table per-page
selector, rendering two chevrons and the wrong width. Explicitly zero out
every visible property except `width`, so Tailwind width utilities (w-24)
still win and the inner `.select-trigger` button owns the visible styling.
2026-04-13 08:47:26 -05:00
FluxCD
0907e92574 chore: update prop image to git.mcintire.me/graham/prop:main-1776087531-49e40f4 [skip ci] 2026-04-13 13:40:58 +00:00
49e40f4253
Merge remote-tracking branch 'origin/main' 2026-04-13 08:38:31 -05:00
a8d4d70abe
live_table polish: border color, control layout, row clicks, pagination
- Tailwind source(none) wasn't scanning deps, so shadcn tokens
  (border-border, bg-muted, text-foreground, ...) used by live_table
  and sutra_ui weren't generated, leaving `border` to fall back to
  currentcolor — a heavy dark border around every table in light mode.
  Add @source directives for deps/live_table/lib and deps/sutra_ui/lib.
- Drop the hardcoded width:100% on the outer .select wrapper so the
  per-page selector's Tailwind w-24 wins instead of stretching across
  the header and overlapping the search box.
- Make whole rows clickable: global click delegator in app.ts forwards
  tr clicks to the first <a href> in the row (the Actions "View" link),
  skipping inner interactive elements. cursor + hover highlight added.
- Replace the default Prev/Next footer with a daisyUI .join/.btn-based
  pager (MicrowavepropWeb.LiveTableFooter), wired up globally via
  :live_table defaults so it applies to every live_table at once.
2026-04-13 08:38:24 -05:00
FluxCD
957e883e7f chore: update prop image to git.mcintire.me/graham/prop:main-1776086506-aff27af [skip ci] 2026-04-13 13:25:54 +00:00
aff27aff7b
Merge remote-tracking branch 'origin/main' 2026-04-13 08:21:17 -05:00
2405c5f169
Stop PropagationGridWorker crash-looping on forecast hours
warm_grid_cache_and_broadcast was re-SELECTing 92k hrrr_profiles rows
with JSONB profile/duct_characteristics columns on every forecast hour.
Row decoding exceeded the 15s default DB connection timeout, killing
the worker before f01-f18 could run. Oban retried from f00 and got
stuck in a loop: for the last several hours, no forecast hours were
being written and even f00 was stale.

Build the weather cache rows directly from the in-memory grid_data
the worker already has (Weather.build_grid_cache_rows/2) and
GridCache.broadcast_put directly. No DB round trip, no JSONB decode.

Also widen the cron from hourly to every 3 hours so a full f00-f18
sweep (~95 min) can actually complete before the next run starts,
and drop the redundant prune_old_scores() at worker start
(PropagationPruneWorker already runs every 15 min). Add a 60s query
timeout to load_weather_grid_from_db as defense-in-depth for the
cold-cache fill path that still uses it.
2026-04-13 08:20:28 -05:00
FluxCD
cea048b360 chore: update prop image to git.mcintire.me/graham/prop:main-1776036001-c020d9c [skip ci] 2026-04-12 23:21:10 +00:00
c020d9c788
live_table polish + status page cleanup
- Compat CSS layer (assets/css/live_table_compat.css) maps the
  shadcn tokens live_table/sutra_ui use (bg-muted, text-foreground,
  border-border, bg-background, select-trigger, input-group, etc.)
  onto daisyUI base colors, and provides hand-written component CSS
  for select/dropdown/input-group/empty so the table has a proper
  surface + a select popover that actually hides when closed.
- Default-sort the Contacts table by most recent inserted_at.
- Beacon detail "Plot path" now encodes lat/lon to an 8-char
  Maidenhead grid when coordinates are known, so the destination
  handed to the path calculator is more precise than the beacon's
  stored 4-char grid.
- Rename BackfillLive -> StatusLive, move route from /admin/backfill
  to /status, retitle "Backfill Dashboard" -> "Status", drop the
  enqueue form (LiveStash + BackfillEnqueueWorker no longer needed
  on this page). Contact edit admin back-link now points at /status.
2026-04-12 18:19:41 -05:00
FluxCD
f0eab6751f chore: update prop image to git.mcintire.me/graham/prop:main-1776034721-ca0a0ea [skip ci] 2026-04-12 23:00:05 +00:00
ca0a0eaa50
Unblock CI: pull igniter into :prod for live_table compile
Since da6b312 (Add live_table dep), every main-branch CI build has
silently failed on `mix deps.compile` inside the Docker builder. The
failure is that live_table ships two unguarded mix tasks —
lib/mix/tasks/live_table.install.ex and
lib/mix/tasks/live_table.gen.live.ex — both of which do
`use Igniter.Mix.Task` at the top level without wrapping it in a
`Code.ensure_loaded?(Igniter.Mix.Task)` check. When Igniter isn't
available, compiling the live_table dep blows up with
`module Igniter.Mix.Task is not loaded`.

I had declared igniter as `only: [:dev, :test], runtime: false`
which meant it was only fetched in those envs, so the prod Docker
build never had it and failed there. The blast radius is every
commit from da6b312 through 263c1bb (14 commits) none of which ever
made it to prod:

  - /users, /beacons, /contacts, /admin/contact-edits live_table
    conversions
  - Admin nav dropdown
  - Cap "Contacts I'm in" at 100
  - Contact detail path-calc link + 10 ft AGL assumption
  - Path calculator callsign tooltip
  - Drop gridmap.org and do QRZ + Google geocoding locally
  - Feed Loss (×) HEEx escape fix
  - Path forecast line chart and refractivity gradient unit fix
  - Float.round/2 crash fix on clear-terrain diffraction loss

Drop the env restriction on igniter so it's available to the prod
build as well. `runtime: false` keeps it out of the release bundle
so there's no runtime bloat, it's strictly a compile-time build
tool for the live_table dep.

Verified locally with a clean `_build/prod` + `MIX_ENV=prod mix
deps.compile` (all 54 deps including live_table now build) and the
full test suite still passes.
2026-04-12 17:58:13 -05:00
263c1bbdf5
Fix /path crash on clear-terrain diffraction loss
TerrainAnalysis.deygout_diffraction/2 short-circuits to integer 0
when the path has no interior points or the principal edge nu is
below the shadow boundary, which then made Float.round/2 blow up
inside compute_loss_budget when the rest of the losses were all
floats. Multiply the diffraction value by 1.0 before it feeds into
the total so clear paths coerce cleanly without touching
TerrainAnalysis's (number()) contract.
2026-04-12 17:50:04 -05:00
8121257c59
Line-chart forecast + unit fix on /path
Replace the stacked-bar 18-hour forecast on the path calculator with
an SVG line chart that mirrors the look of the point-detail sparkline
on /map:

- polyline through every hour with per-tier stroke color
- score gridlines at 0 / 50 / 100
- white "now" marker outlined in the tier color
- highlighted best-hour dot with score label
- first / middle / last time labels along the x-axis
- Improving / Steady / Declining trend chip in the header

Rendered server-side in HEEx as a viewBox="0 0 300 96" SVG so it
scales responsively and inherits the daisyUI text color for axes.
The Best / Worst summary line under the chart is preserved.

Also fixes the Path Weather Avg card: the "Refrac Gradient" cell was
the only stat that split its units out onto a second line in a
smaller muted row. Move "N/km" up beside the value so the cell reads
the same as Pressure (mb), PWAT (mm), BL Depth (m), etc.
2026-04-12 17:49:16 -05:00
547b8451c0
Render literal × in "Feed Loss (×2)" on /path
HEEx attribute values aren't run through Elixir's string escape
machinery, so "Feed Loss (\u00d72)" was showing up verbatim in the
Power Budget card instead of collapsing to the multiplication sign.
Swap the escape for the literal U+00D7 character.
2026-04-12 17:46:59 -05:00
99e7560601
Drop gridmap.org dependency, resolve callsigns locally
Instead of shelling out to https://gridmap.org/locate/:callsign for
callsign → lat/lon lookups, ports the resolver pipeline from gridmap-web
into this project so the whole flow runs in-process.

New modules:

- Microwaveprop.Qrz — cache facade over the QRZ.com XML callsign API.
  Looks up from qrz_callsigns first, falls back to a live fetch, and
  upserts the result with a configurable cache_ttl_hours (default
  168h / 7 days).
- Microwaveprop.Qrz.Client — HTTP/XML client against
  https://xmldata.qrz.com/xml/current/. Holds the session key in an
  Agent, transparently re-logs-in on :session_expired, and parses
  responses via xmerl.
- Microwaveprop.Qrz.Callsign — Ecto schema for the qrz_callsigns
  cache table, binary_id primary key per project convention.
- Microwaveprop.Qrz.Record — slim struct with only the 11 fields
  we actually consume (identity, name, grid, address, lat/lon).
  The full XML payload stays in the raw :data jsonb column for
  anyone who wants the other ~40 QRZ fields.
- Microwaveprop.Geocoder — Req-based client against the Google Maps
  Geocoding API. Only called as a fallback when QRZ has no explicit
  <lat>/<lon> for the callsign.
- Microwaveprop.CallsignLocation — orchestrator. Reads the
  callsign_locations cache, on miss calls Qrz then either uses QRZ's
  coords directly or geocodes the formatted address, snaps to an
  8-char Maidenhead grid via Microwaveprop.Radio.Maidenhead, and
  upserts the result.

Microwaveprop.Radio.CallsignClient.locate/1 is rewritten to delegate
to CallsignLocation.lookup/1 and shape the response back to the
existing {:ok, %{callsign, gridsquare, lat, lon}} contract so the
callers in PathLive and RoverLive don't change.

Wiring:

- priv/repo/migrations/20260413000000_create_qrz_callsigns_and_callsign_locations.exs
  creates qrz_callsigns and callsign_locations with unique indexes
  on :callsign.
- Microwaveprop.Qrz.Client added to the application supervision tree
  so the session Agent is started.
- :xmerl added to extra_applications so the release bundles it.
- config/test.exs wires Req.Test plugs for Microwaveprop.Qrz.Client
  and Microwaveprop.Geocoder, forces cache_ttl_hours: 0 so the cache
  never short-circuits test-level stubs, and supplies dummy QRZ
  credentials.
- config/runtime.exs pulls QRZ_USERNAME / QRZ_PASSWORD / QRZ_AGENT
  and GOOGLE_API_KEY from the environment so prod and dev can
  configure both upstream keys out of band.

Tests (ported verbatim from gridmap-web):

- test/microwaveprop/qrz/callsign_test.exs — schema/changeset
- test/microwaveprop/qrz_test.exs — cache hit, cache miss, TTL
  behavior, upsert on stale, error passthrough, case-insensitive
  input
- test/microwaveprop/geocoder_test.exs — success, zero results,
  request denied, transport error
- test/microwaveprop/callsign_location_test.exs — end-to-end flow
  including the QRZ-lat/lon shortcut and the missing-address error
  path

All 1294 tests still pass. Credo strict clean.
2026-04-12 17:43:29 -05:00
f733ddb6ac
Convert /contacts list to live_table
Replaces the hand-rolled sort/search/pagination on /contacts with
LiveTable.LiveResource. Sortable columns for station1/2, grid1/2,
band, mode, distance_km, qso_timestamp, and inserted_at; searchable
across both stations, both grids, and mode. Custom renderers preserve
the enrichment summary badge (with per-status tooltip), the invalid
flag column, and the qso/inserted_at formatters.

The reciprocal-grouping block has been dropped per the brainstorming
decision — every QSO now shows as its own row, which avoids fighting
live_table's one-row-per-record model. A "View" action links to the
detail page instead of the previous row-click handler (live_table's
stream dom_ids are random UUIDs so row-click by record id is no
longer practical without forking the library).

Test updates:
- sort test switched to ?sort_params[station1]=asc (live_table URL
  format; the old ?sort_by/?sort_order params are gone)
- pair-search test removed since live_table does a single ILIKE
  across searchable fields and no longer supports the two-callsign
  intersection that Radio.list_contacts/1 used to do; single-term
  search is exercised instead
- pagination test asserts the presence of "Page" (live_table's
  pagination text is "Page N" without the total count)

Also converts the stray cond-with-single-clause in
UserManagementLive.Index's delete handler to if/else (credo fix).
2026-04-12 17:22:15 -05:00
606340e1c7
Convert /admin/contact-edits list to live_table
The pending-edits list on the admin review page now sorts through
live_table. Columns for contact, submitted-by user, changed-fields
summary, and submitted timestamp are rendered via 2-arity custom
renderers so the Ecto-preloaded :contact and :user associations stay
accessible — live_table's data_provider path skips select_columns so
the full struct (with preloads) reaches the renderer.

The review drawer, diff table, approve/reject flow, and note textarea
all stay as-is; after approve/reject the page push_patches back to
its current path so live_table re-runs handle_params and the list
refreshes.

Pending count in the header now reads Radio.pending_edit_count/0
instead of length(@edits) since we no longer hold the full list in
socket state.
2026-04-12 17:18:47 -05:00
f9264908db
Convert /beacons approved list to live_table
Replaces the stream-backed static table for approved beacons with
LiveTable.LiveResource, giving users column sorting and full-text
search across callsign and grid. Custom renderers preserve the
frequency/EIRP formatting, keying label, and on-air badge so the
visual output matches the previous daisyUI cells.

A new Beacons.approved_beacons_query/0 returns the base Ecto query
(`where: approved == true`), assigned onto socket.assigns.data_provider
in mount so live_table's handle_params uses it as the root query and
applies sort/search/paginate on top. The leaflet map above the table
keeps its existing phx-hook+phx-update=ignore setup, and the pending-
approval admin section stays as a stream-backed `<.table>` — live_table
is single-resource-per-liveview and the pending list is a distinct
admin concern anyway.

PubSub updates (beacon created/updated/deleted) now push_patch to the
current path so live_table re-runs handle_params and the table
refreshes in place.
2026-04-12 17:15:05 -05:00
67d64109e6
Contact detail link to open in Path Calculator
Adds an "Open in Path Calculator" action to the contact detail
header. The link navigates to /path with source/destination prefilled
(preferring Maidenhead grids over station callsigns, since grids
are exact and callsigns go through a QRZ + geocoder round-trip)
and the contact's band selected. PathLive's handle_params already
auto-runs the calculation when source + destination are present,
so the user lands straight on the computed path ready to tweak
TX power, antenna heights, and gains.
2026-04-12 17:13:05 -05:00
bcfda54897
Assume 10 ft AGL antennas on contact detail path analysis
TerrainAnalysis.analyse was being called with default 0 m antenna
heights on both ends of a QSO's path, which makes the knife-edge
diffraction math pretend the radios are sitting directly on the
terrain surface. Pass 3.048 m (10 ft) for both ant_ht_a and ant_ht_b,
and shift the forward/reverse elevation angles to match. This is
only a display assumption on /contacts/:id — terrain worker paths
computed elsewhere are unaffected.
2026-04-12 17:11:49 -05:00
be9c215515
Tooltip explaining callsign-based destination lookup
Hovering the dotted-underlined "callsign" in the Destination label
on /path now surfaces a tooltip explaining that callsign lookup
queries QRZ for the licensee's mailing address and geocodes it, so
the resulting position may differ from where the station actually
operates. Gives users a reason to enter a grid directly when they
know it's more accurate than a QRZ address.
2026-04-12 17:07:56 -05:00
da6b312213
Add live_table dep and convert /users to use it
Wire up gurujada/live_table 0.4.1 as a dependency (alongside its
transitive sutra_ui and optional igniter requirement) with the
:live_table config pointing at our Repo and PubSub. Forced oban_web
override so the path-pinned vendored copy still wins over live_table's
hex constraint.

Convert the admin /users page to use LiveTable.LiveResource with
sortable, searchable columns for callsign, name, and email. Custom
renderers preserve the daisyUI admin badge and the pending/confirmed
date cell. Hidden id field stays in the query so the edit and delete
actions can target a record by id.

Updated the delete test to push the delete event directly with an id
payload; the stream dom_ids under live_table are random UUIDs so
tr#users-<id> selectors no longer work.
2026-04-12 17:07:07 -05:00
f3d75373dd
Admin-only nav dropdown
Collapse the three admin-only links (Users, Contact edits, Oban) into
a single "Admin" dropdown in the horizontal nav so the top bar isn't
cluttered with admin controls for the one admin account. Non-admins
see nothing extra.
2026-04-12 17:07:07 -05:00
FluxCD
84ef7fcd9c chore: update prop image to git.mcintire.me/graham/prop:main-1776031478-7463cdc [skip ci] 2026-04-12 22:05:51 +00:00
7463cdcc7b
Cap "Contacts I'm in" at 100 most recent
The profile page's involving-contacts query now limits to the 100
most recent rows (ordered by qso_timestamp desc). A small note is
shown under the heading when the cap is hit so users know there's
more data they're not seeing.
2026-04-12 17:04:11 -05:00
FluxCD
6515f84079 chore: update prop image to git.mcintire.me/graham/prop:main-1776031121-47b104e [skip ci] 2026-04-12 21:59:50 +00:00
47b104e3cf
Full-row click navigation in "Contacts I'm in"
Clicking anywhere in a row on the /u/:callsign involving-contacts
table now navigates to that contact's detail page, matching the
click affordance on the main /contacts list.
2026-04-12 16:58:15 -05:00
FluxCD
47e956b0e4 chore: update prop image to git.mcintire.me/graham/prop:main-1776030114-1013df6 [skip ci] 2026-04-12 21:42:45 +00:00
1013df6858
"Contacts callsign is in" section on /u/:callsign
Add a new card on the profile page listing every contact where the
callsign appears as either station1 or station2, case-insensitive,
newest first. Backed by a new `Radio.list_contacts_involving_callsign/1`
query with unit coverage for station1/station2 matching, case handling,
ordering, and blank input.
2026-04-12 16:41:28 -05:00
FluxCD
3a1da13eab chore: update prop image to git.mcintire.me/graham/prop:main-1776029613-ad041cc [skip ci] 2026-04-12 21:34:43 +00:00
ad041ccf4a
Nav reorder + path calc link from beacon detail
Move Scoring Algorithm directly above About in map and weather-map
sidebars; reorder the horizontal nav so Submit sits before Contacts
to match.

Add a "Plot path to beacon" button on the beacon detail page that
navigates to /path with destination, band (snapped via BandResolver),
and dst_height_ft prefilled from the beacon.
2026-04-12 16:33:09 -05:00
FluxCD
73dbe16691 chore: update prop image to git.mcintire.me/graham/prop:main-1776029012-810f5a2 [skip ci] 2026-04-12 21:24:43 +00:00
810f5a22ce
Beacon list map, profile page polish, nav ordering fix
/beacons gains a Leaflet map above the table showing every approved
beacon as a circle marker with a popup that links to the detail page.
New BeaconsListMap JS hook (assets/js/beacons_list_map_hook.ts) walks a
data-beacons JSON payload, fitBounds over the marker set, and mirrors
the format_freq/1 integer-comma formatting used on the detail view so
the popups match the rest of the UI. Approved/on-air beacons render
green, off-air beacons render gray. BeaconLive.Index.mount/3 now
materializes the list into an assign so it can encode it into the data
attribute; the live PubSub path keeps the map in sync on
create/update/delete.

Profile (/u/:callsign) polish: drop the hero-radio, hero-signal, and
hero-information-circle icons per request, and replace the daisyUI
`avatar placeholder` wrapper with a plain flex-centered circle so the
initial letter actually lives inside the circle instead of anchored to
the top-left corner (daisyUI 5 removed the old placeholder-centering
behavior).

Navigation ordering: in the top navbar (layouts.ex) Contacts now sits
immediately before Contact Map instead of after it. All three vertical
sidebars already had them adjacent in the correct order, so only the
horizontal nav changed.
2026-04-12 16:23:09 -05:00
FluxCD
f844c9746c chore: update prop image to git.mcintire.me/graham/prop:main-1776028414-fc24536 [skip ci] 2026-04-12 21:14:39 +00:00
fc245367e3
User profiles at /u/:callsign, flexible band input, assorted UX cleanup
Profile page:
  * New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
    page showing a user's contacts and beacons. Resolves case-
    insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
    callsigns redirect to /. Uses daisyUI card / stats / table
    components with an avatar-placeholder initial and hero icons.
  * Accounts.get_user_by_callsign/1 (case-insensitive) plus
    Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
    back the page. Beacons list includes both approved and pending so
    owners see their drafts.
  * The top nav bar and the three LiveView sidebars (MapLive,
    WeatherMapLive, ContactMapLive) now render the logged-in callsign
    as a navigate link to /u/:callsign instead of a static label.
  * Nine new tests cover the lookup, the LiveView render, and the
    ownership-scoped queries.

Flexible band input:
  * New Microwaveprop.Radio.BandResolver module converts any of:
    ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
    insensitive), numeric frequency strings ("903.100", "10368.000"),
    and canonical MHz integers into the one of the site's known bands.
    Returns the nearest allowed band for numeric inputs >= 900 MHz,
    nil otherwise.
  * 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
    AdifImport.@allowed_bands, and the BandResolver list so "33cm"
    round-trips end-to-end.
  * AdifImport and CsvImport now delegate band resolution to
    BandResolver, and Radio.create_contact/2 normalizes the :band attr
    on the way in so the manual form and any API callers benefit too.
    CsvImport's "invalid band" tests previously used 99999 MHz which
    the new resolver snaps to the nearest allowed band; swapped to
    "notaband" which is truly unresolvable.

Contacts and beacons list UX:
  * Remove the "Submitted" column from /contacts — it duplicated info
    already visible on the detail page and was pushing the real
    columns off narrow viewports. submitted_cell/1 and its three
    column-specific tests go with it.
  * Hide the Lat / Lon columns from /beacons — six decimal places of
    coordinates weren't useful next to the grid square and took a
    disproportionate amount of row width.
2026-04-12 16:13:08 -05:00
FluxCD
4ce7b21b3b chore: update prop image to git.mcintire.me/graham/prop:main-1776026588-eedd91c [skip ci] 2026-04-12 20:43:32 +00:00
eedd91c6ee
Fix /map prod crash on reconnect and redraw reach polygon on band change
Two bugs on the propagation map:

1. LiveStash reconnect path crashed the LiveView with KeyError on
   :initial_scores_json. stash_assigns/2 only persists selected_band and
   selected_time, but the recovered-socket branch returned early without
   rebuilding any of the other mount-time assigns (bands, bounds, the
   initial score JSON payload, grid/radar toggles, antenna height).
   Users on the Phoenix longpoll fallback — or anyone whose websocket
   reconnected after a brief network blip — saw a blank page and a
   server-side 500. Refactor mount/3 to always compute ephemeral assigns,
   using recovered selected_band / selected_time when present and
   falling back to defaults otherwise.

2. The propagation reach polygon shown after clicking a point never
   redrew when the user changed bands or scrubbed the forecast timeline.
   The redraw logic only ran inside the point_detail handler and was
   gated on `hull.length >= 3`, so the stale polygon from the old band
   stuck around whenever the new band had no coverage at the clicked
   point. Extract a redrawReachPolygon/0 helper on the Leaflet hook that
   always clears the previous polygon first and pulls the tier color
   from the current score at the clicked location, then call it from
   update_scores (band change + server time scrub) and the cached
   timeline-scrub path. The point_detail handler delegates to the same
   helper so the polygon stays in sync with both the score grid and the
   detail panel.
2026-04-12 15:42:45 -05:00
FluxCD
e7534bc784 chore: update prop image to git.mcintire.me/graham/prop:main-1776026212-85036ef [skip ci] 2026-04-12 20:37:30 +00:00
85036eff21
Add toggleable weather radar overlay, disable rain-scatter display
New "Weather radar" toggle in /map's control panel (mobile + desktop)
layers an ECCC GeoMet WMS composite dBZ mosaic over the propagation
heatmap. The Radar_1km_dBZ-Extrapolation layer covers CONUS + Canada at
1 km resolution, updated every ~6 minutes. Tiles are lazy-created on
first toggle so visitors who never open the panel never hit the WMS
service. Refresh is driven by a setInterval that calls setParams with a
cache-busting timestamp, matching ECCC's publish cadence.

Rain-scatter display (NEXRAD cell markers + "Rain Scatter" section in
the point-detail panel) is disabled for now. The live radar overlay
answers "is there rain near this point?" more directly, and the scatter
propagation feature needs more work before it's useful. The Elixir
start_async, handle_async, and fetch_rain_scatter helpers are removed.
The JS drawScatterMarkers / buildScatterBlock paths remain as harmless
dead code since they're guarded by a `rain_scatter` field the server no
longer sends — easy to re-enable later by restoring the payload key.
2026-04-12 15:36:29 -05:00
FluxCD
e2dc5ed225 chore: update prop image to git.mcintire.me/graham/prop:main-1776025153-b49c089 [skip ci] 2026-04-12 20:20:27 +00:00
b49c08914e
Force 24h timestamps and let logged-in owners edit their contacts directly
Submit and contact-detail forms now use a plain text input for the QSO
timestamp with a "YYYY-MM-DD HH:MM" placeholder and regex pattern. The
native datetime-local input ignores lang="en-GB" on macOS/iOS when the
system clock is 12-hour, so it was rendering AM/PM against the user's
wishes. Ecto's :utc_datetime cast already accepts this format (verified
space+seconds, T+seconds, and both with and without seconds/Z).

Contact edit workflow grows a third branch: admins still apply directly,
non-owners still go through the admin review queue, and now logged-in
owners of a contact (user_id == current_user.id) can apply edits
directly too. New helpers Radio.owner?/2 and Radio.apply_owner_edit/3
reuse the existing normalize + diff + apply_edit_to_contact path so the
grid-change → enrichment re-enqueue pipeline kicks in automatically.
Anonymous contacts (user_id nil) and other users' contacts both return
{:error, :not_owner}.

Nine new tests cover owner?/2 and apply_owner_edit/3 including the
no-changes, wrong-user, and anonymous-contact paths.
2026-04-12 15:18:47 -05:00
FluxCD
ae76aea515 chore: update prop image to git.mcintire.me/graham/prop:main-1776024864-265669f [skip ci] 2026-04-12 20:15:27 +00:00