- CacheTest: fix sweep timing race by using negative TTL (-1)
instead of positive TTL (1) for already-expired entries
- ScoreCache: replace ETS match-spec DateTime comparisons with
:ets.foldl + DateTime.compare — DateTime structs are maps in
Elixir >= 1.15 and ETS can't compare maps with :< / :> guards
- Accounts: drop unsupported returning: true on delete_all,
return [] for expired tokens list
- Backtest: catch ArgumentError from String.to_existing_atom
for unknown feature names, preserving the helpful Mix.Error
- ContactLive IndexTest: invalidate monthly_bars cache before
assertion so test data is visible
- RoverLocationsLive MapTest: invalidate cached points before
assertion
- StatusLiveTest: add DB cleanup in setup to reduce test
interference; parameterize NARR candidate coordinates
- Mailer: apply_defaults/1 sets From and Reply-To headers
- AboutLive: content rendering, empty stats, and contact count display
- Fix has_many :beacons association (wrong module path)
- Fix router pipeline order: serve_markdown before accepts,
after secure headers so markdown responses get security headers
- Fix notify_listener_test Process.sleep regression
- Update findings.md test coverage gap status
Show on /weather where the detected duct geometry traps each microwave
band. Overview mode bins cells by their lowest trapped frequency
(Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't
reach the selected band. Cutoff is pre-computed per cell in both
writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from
CellValues), persisted to ScalarFile, and shipped to the JS hook via
the existing binary cell pack. Also cleans up six pre-existing
length/1 credo warnings in unrelated test files.
POST /api/v1/beacon-monitor/measurements accepts one measurement per
integration window from a propmonitor client, authenticated by the
BeaconMonitor token. Records noise floor, signal peak/avg dBFS, SNR,
signal-active-fraction, gain, and frequency for later correlation
against weather and propagation scores.
Beacon UUID must resolve to an approved + on-the-air beacon (404
otherwise — the client drops 404s without retry). Retries with the
same (monitor, beacon, measured_at) are idempotent: a unique index
makes the second insert a 409. Every accepted upload stamps the
monitor's last_seen_at — measurements *are* the heartbeat.
MonitorAuth is a separate plug from the user API-token Auth plug;
monitors are not users. The rate limiter buckets each monitor by id so
retry storms after a 5xx don't burn the shared anon-IP bucket.
PathCompute.compute/5 now accepts an :on_progress callback and emits
9 named stages (resolve → terrain → HRRR grid → atmospheric → sounding
→ scoring → budget → forecast → ionosphere). PathLive runs the compute
in start_async, streams {:compute_progress, step, total, label}
messages from the callback, and renders the current stage + step/total
in the disabled button alongside a daisyUI progress bar — replacing
the static "Computing..." while the multi-second pipeline runs.
handle_async covers :ok, :error, and {:exit, _} (last logs per the
CLAUDE.md async-error rule). Existing PathLive tests that asserted on
result content after live() were switched to render_async(lv) so they
wait for the task; new tests cover the progress callback ordering and
the in-flight label rendering.
- /u/:callsign profile no longer leaks private contacts or pending beacons;
Radio.list_contacts_for_user/2 and Beacons.list_beacons_for_user/2 now
filter by viewer (owner/admin see everything, others see only public).
- Radio.create_contact/2 places user_id on the struct before
submission_changeset so validate_user_or_email/1 accepts authenticated
submissions that omit submitter_email.
- valkey_test.exs runs async: false; its setup mutates global Application
env and Process registry, which otherwise crashed concurrent ConnCase
GridCache.clear/0 calls (Mox.UnexpectedCallError on SCAN).
- Beacon detail endpoints (LiveView + REST API) now hide unapproved
beacons from anonymous and unauthorized viewers; only the submitter
and admins can see pending records before approval. Adds
Beacons.get_visible_beacon/2 with scope-aware checks.
- API contact pagination now honors per_page end-to-end.
Radio.list_contacts/1 accepts :per_page and clamps to 200.
- API rate limiter: ETS table is now owned by a long-lived Sweeper
GenServer (won't die with a request task); Sweeper periodically
prunes expired-window rows to bound memory; init_table/0 race is
rescued.
- /scores/cells and /weather/cells: add per-IP rate limiting and a
shared GridBounds clamp/413 guard so global / oversized viewports
no longer drive unbounded binary responses.
- NEXRAD PNG unfilter (sub/up/average/paeth): replace acc++[byte]
+ Enum.at(acc, idx-bpp) with O(n) binary recursion. Decode time
for the 12200x5400 n0q frame goes from quadratic to linear.
- LiveTableFooter.parse_page and ScoresFile.fetch_bound: switch
String.to_integer/String.to_float to Integer.parse/Float.parse,
fall back to defaults instead of raising.
- PathLive and MapLive band-event handlers: replace
String.to_integer(params["band"]) with parse_int / parse_band_param
so a non-numeric band parameter no longer crashes the LiveView.
Replace the markdown-rendered /docs/api page with a structured
LiveView modelled on the Tailwind UI Protocol template — fixed
left sidebar with grouped section nav, hero with metadata dl,
two-column rows where prose sits next to a sticky code sample,
and endpoint cards with color-coded HTTP method tags.
Adds a separate API-token section under /users/settings (distinct from
the beacon-monitor token list, since API tokens grant full account
access). The plaintext is surfaced exactly once via flash on creation;
only the SHA-256 hash is persisted, so revocation is the only path back
if the user loses it.
Also fixes the openapi.yaml link on /docs/api: the relative path
resolved to /docs/openapi.yaml from a no-trailing-slash URL and 404'd.
Two issues made docs/api hard to read:
1. The site's custom Markdown parser only recognized `- ` bullets,
so the README's `* ` bullets rendered as a single run-on paragraph
with literal asterisks. Extended the parser (and tests) to accept
the full CommonMark bullet set: `-`, `*`, `+`.
2. The shared .markdown-content container is 88rem wide. Comfortable
for /algo's wide tables but uncomfortable for monospace prose,
which prefers ~80ch. Added a .api-docs modifier class on the
/docs/api page that drops max-width to 60rem, allows table
cells to wrap, and slightly downsizes headings.
Adds bearer-token authenticated REST API at /api/v1 covering every
action a non-admin user can perform on the website: contact + beacon
submission, beacon-monitor management, propagation queries, profile
read/update, and self-service API token issuance/revocation.
Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown
once at creation), RFC 9457 problem+json error responses, RFC 9651
RateLimit-* headers backed by an ETS bucket (600/min per token,
60/min per anonymous IP, 30/min on /auth/tokens), private-contact
filtering by viewer.
Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml
(OpenAPI 3.1 spec covering every endpoint, response, and schema).
Tests: 124 new tests across schema, plug, error renderer, rate
limiter, fallback, and every controller. 16/17 API modules at 100%
line coverage; FallbackController at 87.5% (one defmodule line, an
Erlang-cover artifact for action_fallback-only modules).
Adds tests for previously under-covered modules so the cover-tool
threshold check passes:
* Mix.Tasks.Prop.Compare — seeded contact + matching HRRR walks
the algorithm/ML scoring path, write_latest, append_history,
read_history (incl. the unparseable-line arm), and the per-band
summary loop.
* Mix.Tasks.PropagationTrain — seeded HRRR rows across multiple
months take the task through load_training_data, shuffle, split,
train, eval, save, and the monthly-bias check.
* Mix.Tasks.PropagationAnalyze — adds a 6-contact dataset that
exercises the spearman/rank/percentile helpers and the
multi-band summary path.
* Mix.Tasks.Unused — smoke tests run/1 with no flags,
--skip-external, and --verbose.
* Mix.Tasks.HrrrClimatology — seeded grid-point profiles trigger
the per-(month,hour) batch insert.
* Microwaveprop.Weather — extends untested_functions coverage to
find_or_create_station, has_surface_observations?,
station_day_covered?, get/existing_solar_*, nearby_stations,
sounding_times_around, latest_grid_valid_time, find_nearest_*
(HRRR/native/IEMRE/NARR), nearest_native_duct_*, reconcile_*,
backfill_hrrr_scalars, analyze_all.
* Microwaveprop.Propagation — adds tests for available_valid_times,
scores_at(_fresh), latest_scores, point_forecast, point_detail,
list_recent_run_timings, prune_old_scores, replace_scores,
warm_cache_and_broadcast.
* Microwaveprop.Backtest.Features — adds duct_usable_*ghz alias
delegations.
* Microwaveprop.Weather.IemRateLimiter — covers the is_pid clause
of registered?/1 with a live PID.
* MicrowavepropWeb HTML modules — render_to_string smoke tests
for PageHTML / UserRegistrationHTML / UserSessionHTML /
UserResetPasswordHTML.
Also pins `test_coverage: [summary: [threshold: 85]]` in mix.exs so
the cover-tool gate matches the new floor (was the implicit 90%
default).
Total goes from 82.77% → 85.06%.
Added profile/sounding setup blocks to SkewtLive tests so the
list_valid_times_near + nearest_sounding_to paths run with real
data. SkewtLive: 33% -> 46%.
Total project coverage: 82.7%.
Aggregates contact counts by calendar month summed across every
year, rendered as an inline SVG bar chart above the table. Months
with no contacts render as a 0-height tick so the x-axis stays
continuous at 12 bars.
Respects the same scope visibility the table uses, so private
contacts a viewer can't see don't leak into the chart counts.
Each <rect> carries data-month / data-month-count for testability
and a <title> for hover.
Two related changes to the contact detail page:
1. The "Flagged invalid" badge was wrapping to two lines on
narrow widths because the daisyUI badge has no explicit
no-wrap class. Add `whitespace-nowrap`, drop the redundant
capitalization on "Invalid", and append the flagger's
callsign so the badge reads "Flagged invalid by W5XYZ".
Hover tooltip carries the timestamp.
2. New `flagged_by_user_id` + `flagged_at` columns on contacts.
`Radio.toggle_flagged_invalid!/2` now takes the admin User
doing the flagging; on unflag it clears both fields so a
subsequent re-flag gets a fresh attribution and the badge
never shows a stale flagger.
3. `Radio.delete_contact!/1` + a Delete button next to Flag.
Admin-only, gated in both the event handler and the template.
Uses LV's `data-confirm` for the destructive-action prompt.
All FK references to `contacts` already declare
`on_delete: :delete_all` (terrain_profiles, contact_edits,
contact_common_volume_radar) so a single Repo.delete!
cascades cleanly.
Three connected changes:
1) Extract PathLive.compute_path/4 + every helper it owned (resolve,
profile grid lookup, sounding/ionosphere readouts, scoring, loss /
power budgets) into Microwaveprop.Propagation.PathCompute. PathLive
now delegates; ~410 lines of dead helpers deleted from PathLive.
2) RoverPathProfileWorker calls PathCompute.compute/4 with the
mission's heights and PathLive's default station params (10 W TX,
30 dBi gains). Stores the full atom-keyed compute output as a
Base64-encoded :erlang.term_to_binary/1 blob alongside the flat
summary fields the rover-planning show table reads. The blob
roundtrips structs and DateTime exactly (Jason.encode would lose
them).
3) PathLive accepts ?rover_path_id=UUID. When set, loads the cached
Path, decodes the term (binary_to_term :safe), assigns it as
@result, and renders normally — no compute_path call. The
rover-planning show table now links rows directly to
/path?rover_path_id=UUID, so a click opens the full Path
Calculator UI from cached data without re-running terrain / HRRR /
sounding lookups.
Bonus prod fixes folded in:
- PathShow's elevation chart attribute (data-* → data-profile JSON)
was crashing the JS hook with 'unexpected character at line 1'.
- Station.changeset now wipes previously-resolved callsign/grid/
lat/lon when the user types into :input — typing 'AA5' early
resolved to a wrong location and locked it; subsequent keystrokes
never re-resolved.
- phx-debounce=600 on the station input so QRZ doesn't get hit on
every keystroke.
New /rover-planning/:id/paths/:path_id route renders the worker-stored
result map directly: geometry, link-budget components, propagation
score, computed_at. The 'Recompute live' button hands off to /path
when the operator wants HRRR-driven loss against current weather.
Clicking the trash icon on a stationary station in :edit mode before
any other phx-change had fired emptied the form's stations params,
which combined with cast_assoc(on_replace: :delete) wiped EVERY
existing station instead of just the one the user clicked. Fall back
to rebuilding the params from the persisted mission's stations when
the form's current params don't already carry them, then delete only
the targeted index.
Mission now carries bands_mhz ({:array, :integer}) — operator picks
one or more bands as multi-checkboxes. enqueue_paths_for builds the
cross product (rover x station x band) and persists each tuple as its
own Path row keyed by (mission_id, rover_location_id, station_id,
band_mhz). The path-profile worker reads band_mhz from the path
itself (legacy single-band jobs without band_mhz in args still resolve
to their unique row).
replace_mission_paths/1 is now a thin alias for reconcile_mission_paths/1
which diffs desired vs actual: stale tuples (old band that the user
unchecked, station they removed, rover-site they deleted) get dropped,
new tuples become :pending and enqueue, surviving :complete rows are
left in place — no more wholesale destruction of already-computed
paths on every edit.
The show table gains a Band column, and band_label() in the mission
summary becomes bands_label() (joins the list with commas).
The add-rover-site form now resolves the input on every change and, if
any existing rover-location shares the same 6-char Maidenhead cell
(~5 km), surfaces a 'Already saved as' link to that location's detail
page. Avoids users accidentally duplicating a site they (or another
operator) already added.
The path-profile worker now also computes the baseline link-loss
budget — free-space path loss, oxygen absorption, baseline H2O loss
(at 7.5 g/m^3 default humidity), plus the already-cached terrain
diffraction — and stores all four numbers in Path.result. The show
page renders the per-row total in a new Loss column. /path still
recomputes against live HRRR weather; this is the offline-readable
cached snapshot.
The 'Rover sites' card no longer renders the long list of every site —
each rover-location now appears once as a path-profile group heading,
which carries its own delete button next to the n/m progress text. The
add-site form stays in place.
The Stationary-stations summary was rendering whatever the user typed
verbatim ('aa5c 33.1889, -96.4517'). Switch to a pair of helpers that
uppercase the callsign (covering legacy mixed-case rows) and synthesize
a 6-char Maidenhead grid from lat/lon when no grid was stored. Bare
coordinates no longer appear in this list — the grid carries the same
location info in a more amateur-radio-native form.
The path-profile worker now also looks up the propagation grid score for
the path midpoint at the mission's band before flipping status to
:complete, so the row never appears 'done' until both terrain and
propagation prediction have run. The show table renders that score as a
red/yellow/green badge in a new Propagation column.
Each rover-location group heading is now a link to /rover-locations/:id
so the user can jump straight to the spot's detail page from the
mission view.
Path table now shows callsign on top with grid muted below, and the
detail-only Min clearance / Diffraction columns are gone. The verdict
badge matched lowercase 'clear/blocked/marginal' but the worker stores
uppercase 'CLEAR/BLOCKED/FRESNEL_PARTIAL/FRESNEL_MINOR' from
TerrainAnalysis, so every cell silently rendered empty. Updated the
guard clauses to the actual upstream strings and added Fresnel variants.
show.ex (rover-planning) and show.ex (rover-locations) auth + delete
flows go from nested case/case/case to a `with` ladder fed by a single
`authenticated/1` clause that returns `{:ok, user}` or
`{:error, :unauthenticated}`. The else block enumerates the small set
of failure tuples instead of rebuilding nested error returns.
Other idiomatic-Elixir tightening:
- `progress_summary/1` reduces with a `tally_path/2` multi-clause
helper (status as a head pattern, not three Enum.count passes).
- `paths_by_rover_location/1` extracts `group_to_pair/1`,
`station_position/1`, and `group_lat/1` so each transformation is a
pattern match instead of an anonymous fn with `&& fallbacks`.
- `error_summary/1` is two clauses (empty vs populated errors)
instead of a pipe-into-case.
- Drag-to-edit show.ex consolidates the working-coords assigns into
`assign_working_coords/3`.
Bug fix uncovered while writing tests: `add_station` on a fresh
/rover-planning/new form was a visual no-op on the first click —
cast_assoc replaced the unseeded default Station struct with the new
params row instead of appending. Now the initial changeset is built
from `%{"stations" => %{"0" => %{"position" => "0"}}}` so add_station
appends from the get-go. Regression test added at
test/microwaveprop_web/live/rover_planning_live_test.exs.
New test branches:
- form: add_station appends on first click + remove_station drops
the targeted row.
- rover-planning show: add_rover_site whitespace-input rejection,
delete_rover_site permission denial.
- rover-locations show: save_edit rejects unauthenticated drivers.
Suite: 3228 tests, 0 failures. Credo strict: 0 issues.
Owner / admin click "Edit" on /rover-locations/:id and the marker
becomes draggable. As they drag, the JS hook pushes `location_dragged`
events with the new lat/lon; the LiveView updates `working_lat` /
`working_lon` / `grid` so the page shows the live preview without
writing to the DB. "Save" persists via Rover.update_location, "Cancel"
reverts both the assigns and the marker position.
Implementation notes:
- Switched the map marker from L.circleMarker to a draggable L.marker
with a divIcon (CircleMarker has no `dragging` handler).
- A second `draggingDotIcon` (amber, larger, grab cursor) makes the
edit affordance obvious.
- Server <-> hook coordination uses push_event:
set_marker_draggable / reset_marker. Drag results come back on
pushEvent("location_dragged", { lat, lon }).
- Coordinates row binds to working_lat/working_lon so the displayed
decimals + derived grid update on every drag, not only on save.
A new "Rover sites" section between Stationary stations and Path
profiles lists the candidate rover locations the mission scores
against, with:
- An add form that takes the same flexible input as station inputs
(callsign, Maidenhead grid, or `lat,lon`); on submit it creates a
global :good rover-location and re-runs the path matrix.
- A per-row trash button visible to the location's owner / admins
that deletes the location and re-runs the matrix.
`RoverPlanning.candidate_rover_locations/1` is now public so the show
page can list exactly what the worker enqueues against. Add/remove
both call `replace_mission_paths/1` so the matrix stays consistent
with the rover-site set.
Anonymous visitors see a sign-in prompt instead of the form.
When a user types raw "lat,lon" coordinates into a station input,
LocationResolver populates only `lat` / `lon` — `callsign` and `grid`
stay blank. The path-profiles "Station" cell was falling through to
"33.189, -96.452" instead of showing a useful grid.
Now `station_label/1` (display) and `station_endpoint/1` (path-URL
destination) derive a 6-/8-char Maidenhead grid from the stored
lat/lon when no grid is on the row, matching the rest of the page's
grid-first presentation.
- Rows in each rover-location group are now phx-click handlers that
navigate to /path with source (rover-loc as 10-char Maidenhead grid),
destination (callsign > grid > coords), band, and rover/station
heights pre-filled — one click takes you to a ready-to-compute Path
Calculator screen.
- Station label now shows "AA5C · EM12kp" when both callsign and grid
resolved, instead of falling back to lat/lon. Bare-grid and
bare-coord stations keep their existing labels.
- Group heading uses Maidenhead.from_latlon(lat, lon, 10) so the
10-char grid is shown in full instead of being truncated to 6
characters.
The flat path table forced you to scan column 1 to mentally group rows
when standing at a specific rover spot. Each rover location now gets
its own card with a heading (grid + lat/lon + notes) and an inner
station-by-station table, so the paths to the fixed stations from the
spot you're at are read in one place.
Sorted by latitude (north → south) so the same location keeps the same
slot across re-renders. `rover_label/1` is removed (its content now
lives in the group heading).
- add_station no longer seeds `"input" => ""` in params, so used_input?/1
treats the new row as untouched and hides the validate_required error
until the user types.
- Show page's format_distance/format_db/format_meters helpers coerce with
`* 1.0` because JSONB returns bare zeros as integers, which crashed
Float.round/2 (FunctionClauseError on /rover-planning/:id render).
- Lat/lon row uses items-start + mt-6 trash so the inputs stay aligned
even when the callsign cell grows from validation feedback.
The hidden 'false' shadow input was rendered after the checkbox, so when
both were submitted the 'false' value overwrote the checkbox 'true' in
the resulting params map. Move the hidden input before the checkbox so
the checked value wins (matching the core_components checkbox pattern).
Adds a full-screen map view rendering every Good rover-location as a
green circle marker (popup links to the detail page). Uses the standard
OSM/Satellite + Maidenhead grid overlay pattern. Bad locations are
excluded. Adds a Map button next to Add location on the index header.
Renames the rover-location status enum and every label that referenced
it. Existing rows are migrated in place. Also touches the consumers:
- Rover.Location enum + default
- RoverLocationsLive index, status filter, form, show, badges
- Rover.Compute and rover_live (only_ideal_locations → only_good_locations)
- RoverPlanning context candidate filter (status == :good)
- RoverPlanning.Show / Form copy
- All rover-related tests
Adds a new globally-scoped, owner-mutable rover-mission tracker:
- /rover-planning paginated table (LiveTable) with View/Edit/Delete
- /rover-planning/new + /:id/edit form: name, band, antenna heights,
notes, "only check against known good locations" toggle (default on),
and a dynamic list of stationary stations entered as callsigns,
Maidenhead grids, or lat,lon pairs (Station changeset geocodes via
LocationResolver, lat/lon stays editable after add)
- /rover-planning/:id show page renders the station list, scope, and a
matrix of computed path profiles (distance, min clearance, diffraction,
verdict) populated as the worker completes each pairing
After save, RoverPlanning enqueues one RoverPathProfileWorker job per
(rover-location × station) pairing. The worker mirrors PathLive's
synchronous compute (ElevationClient + TerrainAnalysis at the mission's
band + heights) and stores the result on the matching path row.
PubSub broadcast on completion lets the show page live-refresh.
Admins can edit/delete any mission; owners can edit their own.
Adds start/end date inputs (UTC) to the sidebar (mobile + desktop) with
Apply dates and Clear buttons. The hook keeps a sliced YYYY-MM-DD on
each line entry so the range check is a string compare during filter
re-application; combines with the callsign filter so e.g. AA5C between
2025-01-01 and 2026-01-01 is one query.
Bare-input phx-change wasn't reliably delivering the value to the
filter_callsign handler. Wrapping the input in a form with phx-submit
+ a Filter button gives explicit submission (Enter or click) on top
of the existing debounced live filter.