Put AirNow stations on the maps, and let a query name one

ae41c81 · Gabriel Morell · 2026-08-11 18:18

31 files +516 -20
Message
{commit_body(@commit)}

Files changed

modified apps/room_air_quality/lib/room_air_quality/aqi_worker.ex
+24 −3
@@ -27,8 +27,27 @@ defmodule RoomAirQuality.Worker do
27 27 |> GenServer.cast(:update_static)
28 28 end
29 29
30 + # A station id names one monitor; a foci means "whatever is nearest". The
31 + # station wins when both are set, and falls back to the foci if that monitor
32 + # has stopped reporting rather than showing nothing.
30 33 def query_place(id, query) do
31 − Storage.get_current_information_for_aqi(id, query.foci_id)
34 + aqsid = Map.get(query, :aqsid)
35 + foci_id = Map.get(query, :foci_id)
36 +
37 + cond do
38 + is_binary(aqsid) and aqsid != "" ->
39 + case Storage.get_aqi_station(id, aqsid) do
40 + [] when not is_nil(foci_id) -> Storage.get_current_information_for_aqi(id, foci_id)
41 + [] -> []
42 + station -> station
43 + end
44 +
45 + not is_nil(foci_id) ->
46 + Storage.get_current_information_for_aqi(id, foci_id)
47 +
48 + true ->
49 + []
50 + end
32 51 end
33 52
34 53 def init(opts) do
@@ -164,9 +183,11 @@ defmodule RoomAirQuality.Worker do
164 183 f |> Map.put(:parameters, p)
165 184 end)
166 185 |> Enum.map(fn data ->
186 + # {lon, lat}, as PostGIS expects -- st_distance against a foci
187 + # compares the two directly.
167 188 point = %Geo.Point{
168 189 coordinates:
169 − {data.latitude |> String.to_float(), data.longitude |> String.to_float()},
190 + {data.longitude |> String.to_float(), data.latitude |> String.to_float()},
170 191 srid: 4326
171 192 }
172 193
@@ -246,7 +267,7 @@ defmodule RoomAirQuality.Worker do
246 267
247 268 point =
248 269 if lat && lon do
249 − %Geo.Point{coordinates: {lat, lon}, srid: 4326}
270 + %Geo.Point{coordinates: {lon, lat}, srid: 4326}
250 271 end
251 272
252 273 RoomSanctum.Storage.change_hourly_obs_data(
modified apps/room_sanctum/lib/room_sanctum/configuration/queries/aqi.ex
+38 −2
@@ -4,13 +4,49 @@ defmodule RoomSanctum.Configuration.Queries.AQI do
4 4
5 5 @primary_key false
6 6
7 + @moduledoc """
8 + Air quality for one place.
9 +
10 + Either a foci -- take whichever monitoring station is nearest to it, which is
11 + what you want for "the air where I live" and keeps working when a station
12 + goes offline -- or a specific station by its AQS id, for watching one
13 + monitor. A station id wins if both are set.
14 + """
15 +
7 16 embedded_schema do
8 17 field :foci_id, :integer
18 + field :aqsid, :string
9 19 end
10 20
11 21 def changeset(source, params) do
12 22 source
13 − |> cast(params, [:foci_id])
14 − |> validate_required([:foci_id])
23 + |> cast(params, [:foci_id, :aqsid])
24 + |> blank_to_nil(:aqsid)
25 + |> validate_a_place()
26 + end
27 +
28 + # The form submits "" for an untouched field, which would otherwise read as
29 + # "a station was chosen" and match nothing.
30 + defp blank_to_nil(changeset, field) do
31 + case get_change(changeset, field) do
32 + value when is_binary(value) ->
33 + case String.trim(value) do
34 + "" -> put_change(changeset, field, nil)
35 + trimmed -> put_change(changeset, field, trimmed)
36 + end
37 +
38 + _ ->
39 + changeset
40 + end
41 + end
42 +
43 + defp validate_a_place(changeset) do
44 + case {get_field(changeset, :foci_id), get_field(changeset, :aqsid)} do
45 + {nil, nil} ->
46 + add_error(changeset, :foci_id, "pick a foci, or name a station")
47 +
48 + _ ->
49 + changeset
50 + end
15 51 end
16 52 end
modified apps/room_sanctum/lib/room_sanctum/storage.ex
+57 −8
@@ -2888,17 +2888,66 @@ defmodule RoomSanctum.Storage do
2888 2888 end
2889 2889
2890 2890 def get_current_information_for_aqi(source_id, foci_id) do
2891 + case nearest_aqi_stations(source_id, foci_id, 1) do
2892 + [] -> [nil]
2893 + [station] -> [station]
2894 + end
2895 + end
2896 +
2897 + @doc """
2898 + The monitoring stations nearest a foci, closest first.
2899 +
2900 + One row per station: the feed replaces the whole set each hour, so the
2901 + current reading and the station itself are the same record.
2902 + """
2903 + def nearest_aqi_stations(source_id, foci_id, limit \\ 5) do
2891 2904 foci = Cfg.get_foci!(foci_id)
2905 + nearby_aqi_stations(source_id, foci.place, limit)
2906 + end
2892 2907
2893 − # not great but I flailed a bit here for a few days and I want to get this done and didn't figure out array_agg
2894 − q =
2895 − from hod in HourlyObsData,
2896 − where: hod.source_id == ^source_id,
2897 − limit: 1,
2898 − order_by: {:asc, st_distance(hod.point, ^foci.place)}
2908 + @doc """
2909 + Monitoring stations nearest a point, closest first.
2910 +
2911 + Both geometries are {lon, lat}, so st_distance compares them directly --
2912 + they used to be transposed in step with each other, which worked until one
2913 + side was normalised.
2914 + """
2915 + def nearby_aqi_stations(source_id, %Geo.Point{} = point, limit \\ 5) do
2916 + from(hod in HourlyObsData,
2917 + where: hod.source_id == ^source_id and not is_nil(hod.point),
2918 + limit: ^limit,
2919 + order_by: {:asc, st_distance(hod.point, ^point)}
2920 + )
2921 + |> Repo.all()
2922 + end
2899 2923
2900 − res = Repo.one(q)
2901 − [res]
2924 + def nearby_aqi_stations(_source_id, _point, _limit), do: []
2925 +
2926 + @doc """
2927 + One named monitoring station, or an empty list if it is not reporting.
2928 + """
2929 + def get_aqi_station(source_id, aqsid) do
2930 + from(hod in HourlyObsData,
2931 + where: hod.source_id == ^source_id and hod.aqsid == ^aqsid,
2932 + order_by: [desc: hod.valid_date, desc: hod.valid_time],
2933 + limit: 1
2934 + )
2935 + |> Repo.all()
2936 + end
2937 +
2938 + @doc """
2939 + Every station currently reporting for a source.
2940 +
2941 + DISTINCT ON rather than a plain select, so this stays one row per station if
2942 + the feed ever keeps more than the latest hour.
2943 + """
2944 + def list_aqi_stations(source_id) do
2945 + from(hod in HourlyObsData,
2946 + where: hod.source_id == ^source_id and not is_nil(hod.point),
2947 + distinct: hod.aqsid,
2948 + order_by: [asc: hod.aqsid, desc: hod.valid_date, desc: hod.valid_time]
2949 + )
2950 + |> Repo.all()
2902 2951 end
2903 2952
2904 2953 alias RoomSanctum.Storage.ICalendar
modified apps/room_sanctum/lib/room_sanctum/storage/air_now/hourly_obs_data.ex
+23 −0
@@ -94,6 +94,29 @@ defmodule RoomSanctum.Storage.AirNow.HourlyObsData do
94 94 :valid_time
95 95 ])
96 96 end
97 + @doc """
98 + The headline AQI for an observation, as `{value, pollutant}`.
99 +
100 + AirNow reports a sub-index per pollutant and the AQI is the worst of them,
101 + which is also the one worth naming -- "52 (PM2.5)" says what is driving it.
102 + Returns nil when a station reported nothing this hour, which is common.
103 + """
104 + def overall_aqi(nil), do: nil
105 +
106 + def overall_aqi(entry) do
107 + [
108 + {Map.get(entry, :pm25_aqi), "PM2.5"},
109 + {Map.get(entry, :pm10_aqi), "PM10"},
110 + {Map.get(entry, :ozone_aqi), "Ozone"},
111 + {Map.get(entry, :no2_aqi), "NO2"}
112 + ]
113 + |> Enum.reject(fn {value, _pollutant} -> is_nil(value) end)
114 + |> case do
115 + [] -> nil
116 + measured -> Enum.max_by(measured, fn {value, _pollutant} -> value end)
117 + end
118 + end
119 +
97 120 def compile_pairs(entry) when is_nil(entry) do
98 121 %{combined: "Pending"}
99 122 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+55 −2
@@ -21,7 +21,8 @@ defmodule RoomSanctumWeb.QueryLive.Show do
21 21 |> assign(:preview_mode, :raw)
22 22 |> assign(:vehicle_positions, [])
23 23 |> assign(:show_route_lines, false)
24 − |> assign(:route_lines, [])}
24 + |> assign(:route_lines, [])
25 + |> assign(:nearby_stations, [])}
25 26 end
26 27
27 28 # Built on first use and then kept: the geometry query is not cheap enough to
@@ -87,6 +88,9 @@ defmodule RoomSanctumWeb.QueryLive.Show do
87 88 |> assign(:query_id, id)
88 89 |> assign(:type, q.source.type)
89 90 |> assign(:visions, [])
91 + # On arrival rather than on the first :update tick, so the page does not
92 + # come up without the context it is mostly there to show.
93 + |> assign(:nearby_stations, nearby_stations(q))
90 94 |> assign(:avail_visions, [])
91 95 |> assign(:avail_sel, :false)
92 96 }
@@ -196,9 +200,58 @@ defmodule RoomSanctumWeb.QueryLive.Show do
196 200 )
197 201 end
198 202
199 − {:noreply, assign(socket, :preview, result)}
203 + {:noreply,
204 + socket
205 + |> assign(:preview, result)
206 + |> assign(:nearby_stations, nearby_stations(socket.assigns.query))}
207 + end
208 +
209 + # For an air quality query, the answer is one station -- so the neighbours
210 + # are the context that makes it readable: whether the number is local or the
211 + # whole city is like that. Ordered closest first, the query's own station
212 + # leading.
213 + defp nearby_stations(%{source: %{type: :aqi}} = query) do
214 + case query.query do
215 + %{aqsid: aqsid} = q when is_binary(aqsid) and aqsid != "" ->
216 + case RoomSanctum.Storage.get_aqi_station(query.source_id, aqsid) do
217 + [station] -> RoomSanctum.Storage.nearby_aqi_stations(query.source_id, station.point, 6)
218 + _ -> from_foci(query, q)
219 + end
220 +
221 + %{foci_id: foci_id} when not is_nil(foci_id) ->
222 + RoomSanctum.Storage.nearest_aqi_stations(query.source_id, foci_id, 6)
223 +
224 + _ ->
225 + []
226 + end
227 + end
228 +
229 + defp nearby_stations(_query), do: []
230 +
231 + # The map speaks stations, not observations.
232 + defp as_stations(observations) do
233 + Enum.map(observations, fn obs ->
234 + %{
235 + place: obs.point,
236 + station_id: obs.aqsid,
237 + name: obs.site_name || obs.aqsid,
238 + short_name: obs.aqsid,
239 + capacity: 0,
240 + address: obs.reporting_areas |> List.wrap() |> List.first(),
241 + lat: obs.lat,
242 + lon: obs.lon
243 + }
244 + end)
200 245 end
201 246
247 + defp aqi_of(observation),
248 + do: RoomSanctum.Storage.AirNow.HourlyObsData.overall_aqi(observation)
249 +
250 + defp from_foci(query, %{foci_id: foci_id}) when not is_nil(foci_id),
251 + do: RoomSanctum.Storage.nearest_aqi_stations(query.source_id, foci_id, 6)
252 +
253 + defp from_foci(_query, _q), do: []
254 +
202 255 def handle_info(:update_sec, socket) do
203 256 visions = Configuration.get_visions(:query, socket.assigns.query_id)
204 257 nv = Configuration.get_visions_nv(:query, socket.assigns.query_id)
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+30 −0
@@ -124,6 +124,8 @@
124 124 queries={[@query]}
125 125 vehicle_positions={@vehicle_positions}
126 126 aircraft={aircraft_from_preview(@query.source && @query.source.type, @preview)}
127 + stations={as_stations(@nearby_stations)}
128 + queried_station_ids={Enum.take(@nearby_stations, 1) |> Enum.map(& &1.aqsid)}
127 129 route_lines={if @show_route_lines, do: @route_lines, else: []}
128 130 route_lines_event={if @query.source && @query.source.type == :gtfs, do: "toggle-route-lines"}
129 131 show_route_lines={@show_route_lines}
@@ -132,6 +134,34 @@
132 134 height="350px"
133 135 />
134 136 </div>
137 +
138 + <%= if @nearby_stations != [] do %>
139 + <%!-- The first is the station this query answers with; the rest are
140 + what the air is doing around it. --%>
141 + <div class="overflow-x-auto mt-3">
142 + <table class="table table-compact w-full">
143 + <tbody>
144 + <%= for {station, index} <- Enum.with_index(@nearby_stations) do %>
145 + <tr class={if index == 0, do: "font-semibold", else: "opacity-70"}>
146 + <td>
147 + <i class={"fa-solid fa-fw mr-1 #{if index == 0, do: "fa-location-crosshairs text-accent", else: "fa-location-dot"}"}></i>
148 + <%= station.site_name || station.aqsid %>
149 + </td>
150 + <td class="text-right whitespace-nowrap">
151 + <%= case aqi_of(station) do %>
152 + <% nil -> %>
153 + <span class="opacity-60">not reporting</span>
154 + <% {value, pollutant} -> %>
155 + <span class="badge badge-accent"><%= value %></span>
156 + <span class="text-xs opacity-70 ml-1"><%= pollutant %></span>
157 + <% end %>
158 + </td>
159 + </tr>
160 + <% end %>
161 + </tbody>
162 + </table>
163 + </div>
164 + <% end %>
135 165 </div>
136 166 </div>
137 167 </div>
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+32 −2
@@ -78,6 +78,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
78 78 stations = case socket.assigns.source.type do
79 79 :gbfs -> Storage.list_gbfs_station_information(socket.assigns.source_id)
80 80 :gtfs -> socket.assigns.source_id |> Storage.list_stops() |> Enum.map(&stop_as_station/1)
81 + :aqi -> socket.assigns.source_id |> Storage.list_aqi_stations() |> Enum.map(&site_as_station/1)
81 82 _ -> []
82 83 end
83 84
@@ -201,9 +202,37 @@ defmodule RoomSanctumWeb.SourceLive.Show do
201 202 }
202 203 end
203 204
204 − # GTFS queries key on :stop, GBFS on :stop_id.
205 + # AirNow replaces its observations every hour, so a reading and the station
206 + # that made it are one record. The name carries the current index, since that
207 + # is what you want off a marker without opening it.
208 + defp site_as_station(observation) do
209 + %{
210 + place: observation.point,
211 + station_id: observation.aqsid,
212 + name: station_label(observation),
213 + short_name: observation.aqsid,
214 + capacity: 0,
215 + address: observation.reporting_areas |> List.wrap() |> List.first(),
216 + lat: observation.lat,
217 + lon: observation.lon
218 + }
219 + end
220 +
221 + defp station_label(observation) do
222 + name = observation.site_name || observation.aqsid
223 +
224 + case RoomSanctum.Storage.AirNow.HourlyObsData.overall_aqi(observation) do
225 + nil -> name
226 + {value, pollutant} -> "#{name} - AQI #{value} (#{pollutant})"
227 + end
228 + end
229 +
230 + # GTFS queries key on :stop, GBFS on :stop_id, AirNow on :aqsid.
205 231 defp station_id_of(%{query: nil}), do: nil
206 − defp station_id_of(%{query: q}), do: Map.get(q, :stop_id) || Map.get(q, :stop)
232 +
233 + defp station_id_of(%{query: q}),
234 + do: Map.get(q, :stop_id) || Map.get(q, :stop) || Map.get(q, :aqsid)
235 +
207 236 defp station_id_of(_), do: nil
208 237
209 238 defp page_title(:show), do: "Offering Detail"
@@ -235,6 +264,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
235 264
236 265 # GTFS names the field :stop, GBFS :stop_id.
237 266 defp station_query_for(:gtfs, id), do: %{"__type__" => "gtfs", "stop" => id}
267 + defp station_query_for(:aqi, id), do: %{"__type__" => "aqi", "aqsid" => id}
238 268 defp station_query_for(type, id), do: %{"__type__" => to_string(type), "stop_id" => id}
239 269
240 270 defp add_station_query(socket, station_id, name) do
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+3 −3
@@ -379,7 +379,7 @@ gl_available_projects={@gitlab_available_projects}
379 379 class=""
380 380 height="600px"
381 381 add_query_event="map-add-query"
382 − queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)}
382 + queried_station_ids={Enum.map(@queries, &station_id_of/1)}
383 383 />
384 384 </div>
385 385
@@ -582,7 +582,7 @@ gl_available_projects={@gitlab_available_projects}
582 582 route_lines_event={if @source.type == :gtfs, do: "toggle-route-lines"}
583 583 show_route_lines={@show_route_lines}
584 584 source_tint={@source_tint}
585 − queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)}
585 + queried_station_ids={Enum.map(@queries, &station_id_of/1)}
586 586 focus={%{lat: focus_lat, lng: focus_lon}}
587 587 focus_zoom={16}
588 588 height="360px"
@@ -683,7 +683,7 @@ gl_available_projects={@gitlab_available_projects}
683 683 route_lines_event={if @source.type == :gtfs, do: "toggle-route-lines"}
684 684 show_route_lines={@show_route_lines}
685 685 source_tint={@source_tint}
686 − queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)}
686 + queried_station_ids={Enum.map(@queries, &station_id_of/1)}
687 687 focus={%{lat: station_lat, lng: station_lon}}
688 688 focus_zoom={16}
689 689 height="360px"
added apps/room_sanctum/priv/repo/migrations/20260811150000_normalise_airnow_points.exs
+42 −0
@@ -0,0 +1,42 @@
1 +defmodule RoomSanctum.Repo.Migrations.NormaliseAirnowPoints do
2 + use Ecto.Migration
3 +
4 + @moduledoc """
5 + AirNow points were stored {lat, lon}, matching how foci used to be stored.
6 + Two transposed geometries compare correctly against each other, so "nearest
7 + station to this foci" worked -- until foci were normalised and the pair
8 + stopped agreeing, which put Boston's nearest monitor in Mozambique.
9 +
10 + The observations table is replaced wholesale every hour, so this only matters
11 + until the next refresh; the writer is fixed alongside. Migrating anyway so
12 + the data is right the moment this ships rather than an hour later.
13 + """
14 +
15 + def up do
16 + for {table, column} <- [
17 + {"airnow_hourly_observations", "point"},
18 + {"airnow_monitoring_sites", "point"}
19 + ] do
20 + execute("""
21 + UPDATE #{table}
22 + SET #{column} = ST_SetSRID(
23 + ST_MakePoint(ST_Y(#{column}::geometry), ST_X(#{column}::geometry)), 4326)
24 + WHERE #{column} IS NOT NULL
25 + """)
26 + end
27 + end
28 +
29 + def down do
30 + for {table, column} <- [
31 + {"airnow_hourly_observations", "point"},
32 + {"airnow_monitoring_sites", "point"}
33 + ] do
34 + execute("""
35 + UPDATE #{table}
36 + SET #{column} = ST_SetSRID(
37 + ST_MakePoint(ST_Y(#{column}::geometry), ST_X(#{column}::geometry)), 4326)
38 + WHERE #{column} IS NOT NULL
39 + """)
40 + end
41 + end
42 +end
added apps/room_sanctum/test/room_sanctum_web/live/airnow_test.exs
+212 −0
@@ -0,0 +1,212 @@
1 +defmodule RoomSanctumWeb.AirNowTest do
2 + @moduledoc """
3 + AirNow on the maps.
4 +
5 + The feed replaces its observations every hour, so a reading and the station
6 + that made it are the same record -- "the stations" are just the current
7 + observations.
8 + """
9 + use RoomSanctumWeb.ConnCase
10 +
11 + import Phoenix.LiveViewTest
12 +
13 + alias RoomSanctum.{Accounts, Configuration, Repo, Storage}
14 + alias RoomSanctum.Storage.AirNow.HourlyObsData
15 +
16 + # Home is in Boston; Maputo is 12,000km away and must never be "nearby".
17 + @stations [
18 + {"BOS-1", "Kenmore Sq", 42.3489, -71.0977, 52},
19 + {"BOS-2", "North End", 42.3631, -71.0542, 41},
20 + {"FAR-1", "Maputo", -25.9038, 32.6284, 18}
21 + ]
22 +
23 + setup %{conn: conn} do
24 + {:ok, user} =
25 + Accounts.register_user(%{
26 + email: "air#{System.unique_integer([:positive])}@example.com",
27 + password: "hello world!hello world!"
28 + })
29 +
30 + {:ok, source} =
31 + Configuration.create_source(%{
32 + name: "AirNow", notes: "", type: :aqi, enabled: true,
33 + user_id: user.id, config: %{"__type__" => "aqi"}
34 + })
35 +
36 + {:ok, foci} =
37 + Configuration.create_foci(%{
38 + name: "Home", user_id: user.id,
39 + place: %Geo.Point{coordinates: {-71.0589, 42.3601}, srid: 4326}
40 + })
41 +
42 + for {aqsid, name, lat, lon, pm} <- @stations do
43 + Repo.insert!(%HourlyObsData{
44 + source_id: source.id, aqsid: aqsid, site_name: name,
45 + lat: lat, lon: lon,
46 + point: %Geo.Point{coordinates: {lon, lat}, srid: 4326},
47 + pm25_aqi: pm, pm25_measured: true,
48 + valid_date: ~D[2026-08-11], valid_time: ~T[16:00:00],
49 + reporting_areas: [name]
50 + })
51 + end
52 +
53 + %{conn: log_in_user(conn, user), user: user, source: source, foci: foci}
54 + end
55 +
56 + defp foci_query(ctx, name \\ "Air at home") do
57 + {:ok, query} =
58 + Configuration.create_query(%{
59 + name: name, notes: "", source_id: ctx.source.id, user_id: ctx.user.id,
60 + query: %{"__type__" => "aqi", "foci_id" => ctx.foci.id}
61 + })
62 +
63 + query
64 + end
65 +
66 + describe "finding stations" do
67 + test "nearest returns the closest first, and never the far one", ctx do
68 + names = Storage.nearest_aqi_stations(ctx.source.id, ctx.foci.id, 2) |> Enum.map(& &1.site_name)
69 +
70 + assert names == ["North End", "Kenmore Sq"]
71 + refute "Maputo" in names
72 + end
73 +
74 + test "every reporting station is listed for the source", ctx do
75 + ids = Storage.list_aqi_stations(ctx.source.id) |> Enum.map(& &1.aqsid) |> Enum.sort()
76 +
77 + assert ids == ["BOS-1", "BOS-2", "FAR-1"]
78 + end
79 +
80 + test "a station can be fetched by its aqs id", ctx do
81 + assert [station] = Storage.get_aqi_station(ctx.source.id, "BOS-1")
82 + assert station.site_name == "Kenmore Sq"
83 + end
84 +
85 + test "an unknown station is empty, not an error", ctx do
86 + assert Storage.get_aqi_station(ctx.source.id, "nope") == []
87 + end
88 + end
89 +
90 + describe "the headline reading" do
91 + test "is the worst sub-index, and says which" do
92 + assert HourlyObsData.overall_aqi(%{pm25_aqi: 52, ozone_aqi: 38, pm10_aqi: nil, no2_aqi: 12}) ==
93 + {52, "PM2.5"}
94 +
95 + assert HourlyObsData.overall_aqi(%{pm25_aqi: 12, ozone_aqi: 90, pm10_aqi: nil, no2_aqi: nil}) ==
96 + {90, "Ozone"}
97 + end
98 +
99 + test "a station reporting nothing has none" do
100 + assert HourlyObsData.overall_aqi(%{pm25_aqi: nil, ozone_aqi: nil, pm10_aqi: nil, no2_aqi: nil}) ==
101 + nil
102 +
103 + assert HourlyObsData.overall_aqi(nil) == nil
104 + end
105 + end
106 +
107 + describe "a query by station id" do
108 + test "is accepted without a foci", ctx do
109 + {:ok, query} =
110 + Configuration.create_query(%{
111 + name: "Just Kenmore", notes: "", source_id: ctx.source.id, user_id: ctx.user.id,
112 + query: %{"__type__" => "aqi", "aqsid" => "BOS-1"}
113 + })
114 +
115 + assert query.query.aqsid == "BOS-1"
116 + assert query.query.foci_id == nil
117 + end
118 +
119 + test "a query naming neither a foci nor a station is refused", ctx do
120 + assert {:error, _} =
121 + Configuration.create_query(%{
122 + name: "Nowhere", notes: "", source_id: ctx.source.id, user_id: ctx.user.id,
123 + query: %{"__type__" => "aqi"}
124 + })
125 + end
126 +
127 + test "a blank station id does not pass for one", ctx do
128 + assert {:error, _} =
129 + Configuration.create_query(%{
130 + name: "Blank", notes: "", source_id: ctx.source.id, user_id: ctx.user.id,
131 + query: %{"__type__" => "aqi", "aqsid" => " "}
132 + })
133 + end
134 + end
135 +
136 + describe "the query page" do
137 + test "lists the station it answers with, then its neighbours", ctx do
138 + {:ok, _live, html} = live(ctx.conn, Routes.query_show_path(ctx.conn, :show, foci_query(ctx)))
139 +
140 + assert html =~ "North End"
141 + assert html =~ "Kenmore Sq"
142 + # the nearest is marked out from the rest
143 + assert html =~ "fa-location-crosshairs"
144 + end
145 +
146 + test "shows each station's current reading", ctx do
147 + {:ok, _live, html} = live(ctx.conn, Routes.query_show_path(ctx.conn, :show, foci_query(ctx)))
148 +
149 + assert html =~ "52"
150 + assert html =~ "41"
151 + assert html =~ "PM2.5"
152 + end
153 +
154 + test "puts them on its map", ctx do
155 + {:ok, _live, html} = live(ctx.conn, Routes.query_show_path(ctx.conn, :show, foci_query(ctx)))
156 +
157 + assert length(Regex.scan(~r/marker-station_/, html)) == 3
158 + end
159 +
160 + test "a station-id query gets neighbours too", ctx do
161 + {:ok, query} =
162 + Configuration.create_query(%{
163 + name: "Just Kenmore", notes: "", source_id: ctx.source.id, user_id: ctx.user.id,
164 + query: %{"__type__" => "aqi", "aqsid" => "BOS-1"}
165 + })
166 +
167 + {:ok, _live, html} = live(ctx.conn, Routes.query_show_path(ctx.conn, :show, query))
168 +
169 + assert html =~ "Kenmore Sq"
170 + assert html =~ "North End"
171 + end
172 + end
173 +
174 + describe "the source map" do
175 + test "shows every station, with its reading on the marker", ctx do
176 + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, ctx.source))
177 + send(live.pid, :update_sec)
178 + html = render_click(live, "toggle-view", %{})
179 +
180 + ids = Regex.scan(~r/marker-station_([A-Z0-9-]+)"/, html) |> Enum.map(&List.last/1) |> Enum.sort()
181 +
182 + assert ids == ["BOS-1", "BOS-2", "FAR-1"]
183 + assert html =~ "AQI 52"
184 + end
185 +
186 + test "a station can be turned into a query from the map", ctx do
187 + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, ctx.source))
188 + send(live.pid, :update_sec)
189 + render_click(live, "toggle-view", %{})
190 +
191 + render_click(live, "map-add-query", %{"station-id" => "BOS-2", "name" => "North End"})
192 +
193 + created =
194 + Configuration.get_queries(:source, ctx.source.id) |> Enum.find(&(&1.name == "North End"))
195 +
196 + assert created.query.aqsid == "BOS-2"
197 + assert created.query.foci_id == nil
198 + end
199 +
200 + test "a station that already has a query is not offered again", ctx do
201 + {:ok, live, _html} = live(ctx.conn, Routes.source_show_path(ctx.conn, :show, ctx.source))
202 + send(live.pid, :update_sec)
203 + render_click(live, "toggle-view", %{})
204 + render_click(live, "map-add-query", %{"station-id" => "BOS-2", "name" => "North End"})
205 +
206 + send(live.pid, :update_sec)
207 + html = render(live)
208 +
209 + assert html =~ ~s(data-has-query="1")
210 + end
211 + end
212 +end

Parents: 395f406