towerops/lib/towerops_web/live/coverage_live/index.ex
Graham McIntire 0e2cc6b7ce chore(coverage,types): clean dialyzer + new tests + ETH canopy provider
Dialyzer:
* Add Lidar.Tile @type, narrow several @specs to match success-typing,
  prefix discarded function returns with `_ =`, fix the broken
  File.stream! call in the ms-buildings worker (it was causing dialyzer
  to mark every private fun unreachable), broaden Antenna.spec key
  types from `float()` to `number()` to match catalog int literals.
* Add :geo, :geo_postgis, :db_connection to plt_add_apps so the dep
  PLT actually contains the modules we use.
* Result: 0 unsuppressed dialyzer warnings (was 50+ in project code).

Tree canopy:
* Replace the dead landfire.gov URL with the ETH Zurich Global
  Canopy Height 10 m product. ETH publishes proper COGs with
  overviews on libdrive.ethz.ch — confirmed working with
  /vsicurl/ byte-range reads. The module now picks the 3°×3° tile
  containing the bbox centroid, with both a `:metres` decoder
  (ETH Byte raster, NoData=255) and a `:landfire_evh` decoder for
  operators self-hosting LANDFIRE EVH COGs.
* Re-enable canopy by default now that there's a working source.

Tests (+105 tests, 0 failures):
* CoverageLive Index/Show/Form/Map — empty/loaded/auth states,
  LiveView event handlers, formatters.
* Coverages.Building changeset + helpers.
* Coverages.TreeCanopy provider tile-URL builder and decoders.
* Workers.MsBuildingsImportWorker (full import flow incl. blank
  lines, MultiPolygon, idempotency, hash fallback) — also fixed
  the partial-index ON CONFLICT bug that prevented imports from
  ever working: the unique index has WHERE ms_footprint_id IS
  NOT NULL, so the worker now uses an :unsafe_fragment conflict
  target that includes the predicate.
* GraphQLDocsController smoke test (was 0%).

Coverage rose from 73.19% → 74% with the major project modules I
touched all moved from 0% / <50% to >75%.
2026-05-07 09:11:17 -05:00

81 lines
2.5 KiB
Elixir

defmodule ToweropsWeb.CoverageLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Coverages
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
_ = if connected?(socket), do: Coverages.subscribe_organization(organization.id)
{:ok,
socket
|> assign(:page_title, t("Coverage"))
|> assign(:organization, organization)
|> assign(:sites, Sites.list_organization_sites(organization.id))
|> load_coverages()}
end
@impl true
def handle_params(_params, _url, socket), do: {:noreply, socket}
@impl true
def handle_event("delete", %{"id" => id}, socket) do
coverage = Coverages.get_coverage!(socket.assigns.organization.id, id)
case Coverages.delete_coverage(coverage) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage deleted"))
|> load_coverages()}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Unable to delete coverage"))}
end
end
@impl true
def handle_event("recompute", %{"id" => id}, socket) do
coverage = Coverages.get_coverage!(socket.assigns.organization.id, id)
case Coverages.queue_compute(coverage) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage queued for recompute"))
|> load_coverages()}
{:error, :compute_in_progress} ->
{:noreply, put_flash(socket, :error, t("Coverage is already being computed"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to queue coverage"))}
end
end
@impl true
def handle_info({:coverage_status, _status, _arg}, socket) do
{:noreply, load_coverages(socket)}
end
defp load_coverages(socket) do
assign(socket, :coverages, Coverages.list_for_organization(socket.assigns.organization.id))
end
@doc false
def status_badge_class("draft"), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
def status_badge_class("queued"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
def status_badge_class("computing"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
def status_badge_class("ready"), do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
def status_badge_class("failed"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
def status_badge_class(_), do: "bg-gray-100 text-gray-700"
end