Commit graph

484 commits

Author SHA1 Message Date
18680b35e5
fix(contacts/map): align line colors with sidebar legend
The polylines were sourcing from --band-* CSS vars (a separate, theme-aware
design palette) while the legend swatches rendered from the Elixir @band_colors
map, so the two diverged for every band — e.g. 10 GHz showed lime on the map
and emerald in the legend. Emit `data-band-color` on each band checkbox and
have the JS hook read from there, making the legend the single source of truth.
Drops the now-redundant theme observer / restyleLines plumbing.
2026-05-05 13:56:02 -05:00
1c6e9005d1
fix(contacts): count every contact in the per-month chart
The chart is a public-facing seasonal summary, not a personal
view, so private contacts should roll into the totals like any
other. Drop the visible_query scope from monthly_bars and query
Contact directly.
2026-05-05 09:35:50 -05:00
56e9755a10
feat(contacts): per-month bar chart on the /contacts page
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.
2026-05-05 09:35:08 -05:00
fb503ed2e7
feat(contacts): record + show flagger attribution; admin delete
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.
2026-05-04 10:15:13 -05:00
068629ee35
ux: brand title and header label as 'NTMS Microwave Propagation' 2026-05-04 08:21:22 -05:00
9bec5721d0
ux(rover-planning): table rows are real <a href> links
Each path-table cell now wraps its content in <.link navigate={...}>,
so the row behaves as a native anchor: hovering shows the destination
URL in the browser status bar, right-click 'open in new tab' works,
middle-click works, and the rendered HTML is meaningful to assistive
tech / scrapers / link-validators.

Replaces the phx-click={JS.navigate(...)} pattern, which gave none of
those affordances and required the user to trust that the row was
clickable. Whole-row hover highlight is preserved via a CSS
:has(a:hover) selector so cell-hover still tints the row.
2026-05-03 16:20:45 -05:00
893a02e1e2
fix(rover-planning): index 'Band' column shows all mission bands
The /rover-planning index was rendering only band_mhz (the legacy
primary single-band column), so multi-band missions with bands_mhz
populated showed just the first band. Switched the renderer to
arity-2 and now pulls the full bands list via Mission.bands/1, so
multi-band missions render '10 GHz · 24 GHz · 47 GHz' etc. Sort
still keys on band_mhz so the column stays sortable on a single
scalar value.
2026-05-03 16:06:07 -05:00
833897e2f5
ux: reword client-error flash to 'Unable to reach the server'
The default 'We can't find the internet' wording blames the user's
connection, but the typical cause on this site is a server-side issue
(deploy in progress, pod OOM, ingress hiccup). Server-specific wording
matches reality and points users at the right thing to wait on.
2026-05-03 16:04:23 -05:00
a4f0e171e8
feat(rover-planning): worker pre-computes full /path output, PathLive renders cached
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.
2026-05-03 14:58:16 -05:00
2327fabe29
feat(rover-planning): cached path detail renders chart + numbers without live recompute
Worker now stores the full per-point analysis (beam height,
Fresnel-zone radius, clearance) on Path.result.path_points, and
PathShow renders a real cached page: terrain elevation chart fed by
the same ElevationProfile JS hook /path uses (feet on Y, miles on X,
LOS + Fresnel overlay), distance / bearing / clearance / antenna
heights all in feet, baseline link-budget breakdown, and the cached
midpoint propagation score with computed_at timestamp.

A Recompute live button hands off to /path for the time-varying bits
(current HRRR conditions, scoring breakdown, sounding, ionosphere)
that aren't worth caching.
2026-05-03 14:44:03 -05:00
2851f467a3
feat(rover-planning): path detail redirects to /path with full UI
The cached path-detail URL was a thinned-down view (geometry +
loss-budget summary + score). The user wanted the same rich display
/path renders — terrain chart with feet axes + tooltips, conditions,
scoring, link/power budgets, forecast — so PathShow now resolves the
row's rover/station/band/heights and push_navigates to /path with
those params baked in. The cached result map on Path.result is still
what the rover-planning show table reads from for fast row rendering;
this just bridges the row click to the canonical Path Calculator.
2026-05-03 14:37:57 -05:00
5e4e75dfb3
fix(about): drop propagation scores stat tile 2026-05-03 14:24:54 -05:00
a0dfdd26af
fix(about): per-table count fallback so missing tables don't blank everything
The whole-block try/rescue around fetch_stats meant any single failing
query (a missing dev table, a transient pg blip) zeroed all 10
counters. Each count_exact/1 + count_estimate/1 now uses Repo.query/2
and falls back to 0 only for its own column. Bumps the inline 9-factor
prose to 10-factor and pulls the contacts headline from the live count
instead of a stale '58k+' string.

Adds a CLAUDE.md note prompting future updates to refresh /about's
prose when factor counts, data sources, or schema scale change.
2026-05-03 14:21:53 -05:00
7ab2aa8a11
feat(rover-planning): row click opens cached path detail, not /path recompute
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.
2026-05-03 14:19:31 -05:00
fe2024275b
fix(rover-planning): async reconcile + strict band match in path worker
Two fixes:

1) Form save / rover-site add/remove no longer runs the path-matrix
   reconcile inline. update_mission and the show LiveView now
   enqueue a single RoverMissionReconcileWorker job and return —
   for missions with many rover sites x bands the inline path was
   firing hundreds of Multi inserts + Oban.inserts on the request.

2) RoverPathProfileWorker.load_path/1 now strictly matches a
   4-tuple (mission_id, rover_location_id, station_id, band_mhz)
   with band_mhz as an integer, plus updates the Oban unique key
   set to include band_mhz so multi-band missions enqueue per-band
   jobs (not just one). Legacy/malformed args are logged and
   dropped instead of crashing the queue with MultipleResultsError.
2026-05-03 14:16:48 -05:00
1d4b2aeedb
fix(rover-planning): edit-mode remove_station deletes only the targeted row
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.
2026-05-03 14:11:38 -05:00
2c88fba1df
feat(rover-planning): multi-band missions + smart reconcile
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).
2026-05-03 14:08:49 -05:00
b247e8a719
feat(rover-planning): warn when new site overlaps an existing 6-char grid
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.
2026-05-03 14:00:47 -05:00
23d002e8e0
feat(rover-planning): cache link-budget loss components per path
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.
2026-05-03 13:58:18 -05:00
327b4fc561
feat(rover-planning): inline per-group delete, drop standalone site list
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.
2026-05-03 13:54:46 -05:00
e84f28643f
fix(rover-planning): truncate long rover-site notes on a single row 2026-05-03 13:48:02 -05:00
39c4c287a5
feat(rover-planning): show callsign + grid (no bare coords) in stationary list
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.
2026-05-03 13:41:53 -05:00
210e941db8
feat(rover-planning): propagation score column + linked location heading
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.
2026-05-03 13:35:54 -05:00
d3b96725c8
fix(rover-planning): two-line station, drop noise columns, fix verdict badge
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.
2026-05-03 13:26:57 -05:00
bd77d1a5b0
feat(rover-locations): edit status + notes alongside marker drag 2026-05-03 13:10:47 -05:00
08ac76a4b8
refactor(rover): pattern-match-first event handlers + missing test branches
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.
2026-05-03 13:01:36 -05:00
89a5cfefd2
feat(rover-locations): drag-to-edit marker with live grid+coord preview
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.
2026-05-03 12:51:12 -05:00
8fd9759e4e
feat(rover-planning): add/remove rover sites inline on the show page
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.
2026-05-03 12:47:07 -05:00
5c3a791665
fix(rover-planning): derive grid from lat/lon for coord-only stations
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.
2026-05-03 12:43:32 -05:00
7edfc64996
fix(path): stop warning on expected native-duct cache misses
`Weather.nearest_native_duct_info/3` returns `{:error, :not_found}`
whenever the midpoint has no native HRRR profile within ±0.07° / ±1 h
— the documented fallback for the sparsely-populated
`hrrr_native_profiles` table (only QSO-worker-touched cells get
ingested). The empty-map fallback was already the right behavior; the
warning was just noise that fired on every /path computation outside
the populated set.

Now the `:not_found` clause silently returns the same fallback shape
without logging.
2026-05-03 12:38:29 -05:00
dbd4a78502
feat(rover-planning): clickable rows + callsign·grid labels + 10-char heading grid
- 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.
2026-05-03 11:49:18 -05:00
a18586046d
feat(rover-planning): group path profiles by rover location
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).
2026-05-03 11:34:36 -05:00
cff0c6775d
fix(rover-planning): suppress fresh-station error + tolerate integer JSONB values
- 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.
2026-05-03 11:29:57 -05:00
eca463add1
fix(rover-planning): keep only_known_good checked across phx-change events
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).
2026-05-03 11:19:14 -05:00
3c9fbcdec6
feat(rover-locations): /rover-locations/map page with Maidenhead overlay
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.
2026-05-03 11:14:58 -05:00
209244f364
refactor(rover-locations): rename status atoms ideal→good, off_limits→bad
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
2026-05-03 11:13:18 -05:00
d1a5442afb
feat(rover-planning): /rover-planning page with path-profile worker
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.
2026-05-03 11:04:11 -05:00
a3fff198c9
feat(contact-map): date-range filter alongside callsign filter
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.
2026-05-03 10:36:34 -05:00
01341d20f3
fix(contact-map): wrap callsign filter in form, add Filter button
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.
2026-05-03 09:54:44 -05:00
d052dcaa91
feat(rover): admins can edit/delete any rover location
Rover.update_location/3 and delete_location/2 now resolve any record
when the actor is_admin. The index + show LiveViews surface Edit/Delete
on every row for admin users, matching the existing owner UX.
2026-05-02 17:04:52 -05:00
558980a191
polish(rover): drop redundant View action; location cell already links 2026-05-02 17:02:56 -05:00
d44351366a
feat(rover): /rover-locations/:id detail page with map + marker
Each row now has a View link (and the location cell links too) to a
new show LiveView that renders the full record alongside a Leaflet
map centered on the marker. Owners get a Delete button there as well.
2026-05-02 17:02:00 -05:00
4a82571cda
feat(rover): two-way grid <-> lat/lon sync on location form
Adds a Maidenhead grid input to the rover-location form. Typing a grid
fills in lat/lon (using square center); editing lat/lon recomputes the
grid at 6-char precision. The phx-change `_target` decides direction so
edits never clobber what the user just typed.
2026-05-02 16:50:18 -05:00
7eafa4c71a
refactor(rover): /rover-locations as paginated table with status filter
Reuses the LiveTable pattern from /contacts and /users so the page gets
sort, search, and pagination. Adds an in-page Ideal/Off Limits filter.
2026-05-02 16:45:56 -05:00
6c5d066121
fix(metrics): defer ETS cron flusher on /metrics scrape
Without the deferral the cron-driven ETS flush GenServer fires every
7.5s alongside Prometheus scrapes, calls PromEx.get_metrics in a Task,
and dies with `Task.await ... time out` (hardcoded 10s) under contention.
Mirrors upstream PromEx.Plug, which calls defer_ets_flush after a
successful render specifically to suppress the cron while scraping is
healthy.
2026-05-02 10:59:24 -05:00
1f8765f757
fix(weather): tolerate stale map_bounds from /map → /weather navigation
/map's PropagationMap hook pushes `map_bounds` on every moveend/zoomend.
When a user live-navigates from /map to /weather, an in-flight bounds
push can land on the new WeatherMapLive (the same LV channel survives
the navigation), and WeatherMapLive's `handle_event/3` had no clause
for it — the LV process crashed with FunctionClauseError, taking the
user's session down.

WeatherMapLive doesn't need viewport bounds — the /weather/cells HTTP
endpoint reads them off the URL the client builds — so the safe fix is
a no-op handler. Tested.
2026-04-30 17:34:38 -05:00
b37692592b
fix(weather): default selected time to current hour, not clock-closest
Past minute 30 of the wall clock the next forecast hour is closer to
utc_now() than the current hour, so the picker would land on (e.g.)
19:00Z when the user is at 18:43Z. HRRR has 19:00Z, but the HRDPS
chain only seeds the *current* hour (18:00Z), so the dual-source map
showed an empty Canadian half whenever we crossed the half-hour mark.

Switch to "most-recent valid_time <= now", with a fallback to the
closest future when nothing on disk is yet at-or-before now (cold
start). Hoist the picker into a public function so it can be unit-
tested deterministically — anchoring the integration test on
DateTime.utc_now() truncated to the hour would have only verified the
new behavior past minute 30.

/weather-ca delegates to the same picker so the two maps agree on
"current."
2026-04-30 13:50:33 -05:00
4901c83916
fix(weather-ca): drop the 1h freshness cutoff so HRDPS data renders
The 1-hour valid_time filter inherited from /weather is wrong for
HRDPS: the model publishes 4x/day with 3-4h publication latency and
the chain step adds ~13 min on top, so the freshest data on disk is
routinely 4-6h old. With the 1h cutoff in place, /weather-ca's
timeline was empty whenever there wasn't an active chain step running,
which left the JS hook with nothing to fetch. The sidebar already
shows the selected valid_time prominently — users can see for
themselves how stale the data is and don't need the page to hide it.
2026-04-30 12:24:34 -05:00
ac3a2517d9
feat(weather): /weather-ca endpoint shows HRDPS-only Canadian data
New LiveView at /weather-ca duplicates the /weather UI but defaults the
viewport to central Canada (55N, -100W, z=4) and renders the map div
with data-source="hrdps". The JS hook reads that attribute and appends
&source=hrdps to every cell fetch; the WeatherTileController routes
those reads through Weather.weather_grid_hrdps_at/2, which only reads
the <vt>.hrdps scalar dir and skips HRRR merging entirely. Timeline is
sourced from ScalarFile.list_valid_times_hrdps/0 so HRDPS-only hours
show up even when no HRRR file exists for the same valid_time.

Also drops "— Unified" from the algo.md H1.
2026-04-30 12:08:48 -05:00
aa8b789d2e
feat(weather/map): persist viewport to URL for reload-stable view
Adds lat/lon/zoom URL params to /weather so reload / share-link
restores the exact map state. The existing ?layer= param keeps
working alongside.

LiveView side:

- mount() reads lat/lon/zoom from query params, clamps to valid
  ranges (lat -90..90, lon -180..180, zoom 4..10), and falls back
  to DFW@z7 when missing or invalid. Initial values flow to the
  hook via three new data attributes.
- handle_event("viewport_changed", ...) accepts {lat, lon, zoom}
  pushed by the hook on debounced moveend, clamps + rounds, and
  push_patches the URL with replace: true so browser history
  doesn't grow an entry per pan tick.
- build_path/2 helper centralises URL construction so select_layer
  and viewport_changed both produce a deterministic param order
  (layer, lat, lon, zoom). Tests assert exact URLs.

JS hook side:

- mounted() reads data-initial-lat/lon/zoom from the el dataset
  with parseFloat/parseInt + Number.isFinite gates, then seeds
  Leaflet's center/zoom from those instead of the previous
  hardcoded (32.897, -97.038, 7).
- moveend handler now also schedules a 400 ms-debounced
  pushEvent("viewport_changed", {lat, lon, zoom}) so the URL
  catches up with whatever the user panned/zoomed to. Separate
  debounce timer from the cells fetch since the URL push and the
  network fetch have different cadence sweet spots.

Tests cover: initial seeding from URL params, clamping of bad
values, viewport_changed event producing the right patched URL,
and the original layer-shareability test (now expecting the full
viewport in the URL).
2026-04-30 09:10:36 -05:00