From 1e67043c29e02abd626b6419818b9d81a13dc199 Mon Sep 17 00:00:00 2001 From: "joseph@dxw.com" Date: Wed, 20 Nov 2024 15:41:45 +0000 Subject: [PATCH 1/6] Remove reference to old control We no longer have multiple pollution layers or a control to switch between them, so this code is unnecessary (and currently throws an error). --- app/javascript/controllers/map_controller.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/javascript/controllers/map_controller.js b/app/javascript/controllers/map_controller.js index 1c03c0f7..13b8b7d3 100644 --- a/app/javascript/controllers/map_controller.js +++ b/app/javascript/controllers/map_controller.js @@ -195,7 +195,6 @@ export default class MapController extends Controller { } updatePollutionLayer() { - this.map.removeControl(this.controls.pollution); this.map.removeLayer(this.layers.pollution); this.addPollutionLayer(); } From bc55efbcf9d7d77e0b2f44a9f63d4cda8598b493 Mon Sep 17 00:00:00 2001 From: "joseph@dxw.com" Date: Wed, 20 Nov 2024 15:32:50 +0000 Subject: [PATCH 2/6] Combine prediction and tab controllers These two controllers have very similar functionality and are both very small, so it makes sense to combine them. I've also cleaned up a few leftover attributes that we're no longer using. --- app/components/day_tab_component.html.erb | 8 ++--- app/components/prediction_component.html.erb | 2 +- app/javascript/controllers/index.js | 3 -- .../controllers/prediction_controller.js | 33 ++++++++++++------- app/javascript/controllers/tab_controller.js | 23 ------------- .../forecasts/_location_selector.html.erb | 4 +-- app/views/forecasts/show.html.erb | 2 +- 7 files changed, 30 insertions(+), 45 deletions(-) delete mode 100644 app/javascript/controllers/tab_controller.js diff --git a/app/components/day_tab_component.html.erb b/app/components/day_tab_component.html.erb index a7819599..3d218df1 100644 --- a/app/components/day_tab_component.html.erb +++ b/app/components/day_tab_component.html.erb @@ -2,11 +2,11 @@ data: { date: @forecast.date.to_s, day: @day, - "tab-target": "dayPrediction", - action: "click->tab#changeTab", + "prediction-target": "dayPrediction", + action: "click->prediction#changeDay", } do %> -
+
<%= @forecast.date == Date.today ? 'Today' : @forecast.date.strftime('%A') %>
@@ -20,7 +20,7 @@
<%= @forecast.air_pollution.label.capitalize %>
-
+
Index <%= @forecast.air_pollution.value %>/10
<% end %> diff --git a/app/components/prediction_component.html.erb b/app/components/prediction_component.html.erb index a1c3ffd2..cb7786cd 100644 --- a/app/components/prediction_component.html.erb +++ b/app/components/prediction_component.html.erb @@ -1,4 +1,4 @@ -
+
<%= name_for_label %>
<%= daqi_level_for_label %>
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 461971b4..39e419ee 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -16,8 +16,5 @@ application.register("map", MapController); import PredictionController from "./prediction_controller"; application.register("prediction", PredictionController); -import TabController from "./tab_controller"; -application.register("tab", TabController); - import NavigationController from "./navigation_controller"; application.register("navigation", NavigationController); diff --git a/app/javascript/controllers/prediction_controller.js b/app/javascript/controllers/prediction_controller.js index 9a005376..4983aca8 100644 --- a/app/javascript/controllers/prediction_controller.js +++ b/app/javascript/controllers/prediction_controller.js @@ -1,23 +1,34 @@ import { Controller } from "@hotwired/stimulus"; export default class PredictionController extends Controller { - static targets = ["showButton", "hideButton", "guidance", "zoneSelector"]; + static targets = ["zoneSelector", "daySelector"]; - toggleGuidance() { - this.showButtonTarget.classList.toggle("hidden"); - this.hideButtonTarget.classList.toggle("hidden"); - this.guidanceTarget.classList.toggle("hidden"); + changeZone() { + this.updateUrl({ zone: this.zoneSelectorTarget.value }); + this.reloadPrediction(); } - changeZone() { - const selectedZone = this.zoneSelectorTarget.value; + changeDay(event) { + const selectedDay = event.currentTarget.dataset.day; - // Update the URL with the new zone - const url = new URL(window.location.href); - url.searchParams.set("zone", selectedZone); - window.history.pushState({}, "", url); + this.updateUrl({ day: selectedDay }); + + // Update contents of daySelector + this.daySelectorTarget.value = selectedDay; + this.reloadPrediction(); + } + + reloadPrediction() { // Submit the form to reload the turbo frame this.zoneSelectorTarget.form.requestSubmit(); } + + updateUrl(newParams) { + const url = new URL(window.location.href); + for (const [key, value] of Object.entries(newParams)) { + url.searchParams.set(key, value); + } + window.history.pushState({}, "", url); + } } diff --git a/app/javascript/controllers/tab_controller.js b/app/javascript/controllers/tab_controller.js deleted file mode 100644 index 93beaf47..00000000 --- a/app/javascript/controllers/tab_controller.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -// Connects to data-controller="tab" -export default class TabController extends Controller { - static targets = ["daySelector"]; - - connect() {} - - changeTab(event) { - const selectedDay = event.currentTarget.dataset.day; - - // Add the new date to the URL - const url = new URL(window.location.href); - url.searchParams.set("day", selectedDay); - window.history.pushState({}, "", url); - - // Update contents of daySelector - this.daySelectorTarget.value = selectedDay; - - // Submit the form to reload the turbo frame - this.daySelectorTarget.form.requestSubmit(); - } -} diff --git a/app/views/forecasts/_location_selector.html.erb b/app/views/forecasts/_location_selector.html.erb index f541f36e..22eba8ef 100644 --- a/app/views/forecasts/_location_selector.html.erb +++ b/app/views/forecasts/_location_selector.html.erb @@ -1,7 +1,7 @@ <%= form_tag(false, method: :get) do %> <%= select_tag :zone, options_for_select(Zone.all.order(:name).map(&:name), @zone.name), - data: { controller: "prediction", "prediction-target": "zoneSelector", action: "change->prediction#changeZone" } + data: { "prediction-target": "zoneSelector", action: "change->prediction#changeZone" } %> - <%= hidden_field_tag :day, @day, data: { "tab-target": "daySelector" } %> + <%= hidden_field_tag :day, @day, data: { "prediction-target": "daySelector" } %> <% end %> diff --git a/app/views/forecasts/show.html.erb b/app/views/forecasts/show.html.erb index 9aaee66a..93b17685 100644 --- a/app/views/forecasts/show.html.erb +++ b/app/views/forecasts/show.html.erb @@ -1,7 +1,7 @@

Air quality forecast

The air quality forecast displays levels of air pollution, ultraviolet rays, pollen, and temperature over the next three days for your area of interest.

- + <%= render "location_selector" %> <%= render "forecast_tabs" %>
From 2c536babf95ae0996fae35a47c3dddaab25154c0 Mon Sep 17 00:00:00 2001 From: "joseph@dxw.com" Date: Wed, 20 Nov 2024 16:43:55 +0000 Subject: [PATCH 3/6] Rename prediction controller to forecast controller The prediction controller doesn't just focus on predictions anymore, so it makes sense to generalise its name. --- app/components/day_tab_component.html.erb | 4 ++-- app/components/prediction_component.html.erb | 2 +- .../{prediction_controller.js => forecast_controller.js} | 2 +- app/javascript/controllers/index.js | 4 ++-- app/views/forecasts/_location_selector.html.erb | 4 ++-- app/views/forecasts/show.html.erb | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) rename app/javascript/controllers/{prediction_controller.js => forecast_controller.js} (92%) diff --git a/app/components/day_tab_component.html.erb b/app/components/day_tab_component.html.erb index 3d218df1..1fd74d79 100644 --- a/app/components/day_tab_component.html.erb +++ b/app/components/day_tab_component.html.erb @@ -2,8 +2,8 @@ data: { date: @forecast.date.to_s, day: @day, - "prediction-target": "dayPrediction", - action: "click->prediction#changeDay", + "forecast-target": "dayPrediction", + action: "click->forecast#changeDay", } do %>
diff --git a/app/components/prediction_component.html.erb b/app/components/prediction_component.html.erb index cb7786cd..44051905 100644 --- a/app/components/prediction_component.html.erb +++ b/app/components/prediction_component.html.erb @@ -3,7 +3,7 @@
<%= name_for_label %>
<%= daqi_level_for_label %>
-
<%= guidance_panel_colour %>" data-prediction-target="guidance"> +
<%= guidance_panel_colour %>"> <%= guidance%>
diff --git a/app/javascript/controllers/prediction_controller.js b/app/javascript/controllers/forecast_controller.js similarity index 92% rename from app/javascript/controllers/prediction_controller.js rename to app/javascript/controllers/forecast_controller.js index 4983aca8..8622b30c 100644 --- a/app/javascript/controllers/prediction_controller.js +++ b/app/javascript/controllers/forecast_controller.js @@ -1,6 +1,6 @@ import { Controller } from "@hotwired/stimulus"; -export default class PredictionController extends Controller { +export default class ForecastController extends Controller { static targets = ["zoneSelector", "daySelector"]; changeZone() { diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 39e419ee..fda0c3a1 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -13,8 +13,8 @@ window.Stimulus = application; import MapController from "./map_controller"; application.register("map", MapController); -import PredictionController from "./prediction_controller"; -application.register("prediction", PredictionController); +import ForecastController from "./forecast_controller"; +application.register("forecast", ForecastController); import NavigationController from "./navigation_controller"; application.register("navigation", NavigationController); diff --git a/app/views/forecasts/_location_selector.html.erb b/app/views/forecasts/_location_selector.html.erb index 22eba8ef..23fd81de 100644 --- a/app/views/forecasts/_location_selector.html.erb +++ b/app/views/forecasts/_location_selector.html.erb @@ -1,7 +1,7 @@ <%= form_tag(false, method: :get) do %> <%= select_tag :zone, options_for_select(Zone.all.order(:name).map(&:name), @zone.name), - data: { "prediction-target": "zoneSelector", action: "change->prediction#changeZone" } + data: { "forecast-target": "zoneSelector", action: "change->forecast#changeZone" } %> - <%= hidden_field_tag :day, @day, data: { "prediction-target": "daySelector" } %> + <%= hidden_field_tag :day, @day, data: { "forecast-target": "daySelector" } %> <% end %> diff --git a/app/views/forecasts/show.html.erb b/app/views/forecasts/show.html.erb index 93b17685..ff84c8ec 100644 --- a/app/views/forecasts/show.html.erb +++ b/app/views/forecasts/show.html.erb @@ -1,7 +1,7 @@

Air quality forecast

The air quality forecast displays levels of air pollution, ultraviolet rays, pollen, and temperature over the next three days for your area of interest.

- + <%= render "location_selector" %> <%= render "forecast_tabs" %>
From eec96fb775d8aa892ee98dae68e168c42356bd33 Mon Sep 17 00:00:00 2001 From: "joseph@dxw.com" Date: Wed, 20 Nov 2024 16:51:37 +0000 Subject: [PATCH 4/6] Store pollutant in the URL Storing the pollutant in the URL means we can persist it and include it in links that are shared. It behaves like the zone selector. --- app/controllers/forecasts_controller.rb | 1 + .../controllers/forecast_controller.js | 7 +++++- .../forecasts/_location_selector.html.erb | 2 +- .../forecasts/_pollutant_selector.html.erb | 25 +++++++++---------- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/app/controllers/forecasts_controller.rb b/app/controllers/forecasts_controller.rb index beebd130..095e2ad7 100644 --- a/app/controllers/forecasts_controller.rb +++ b/app/controllers/forecasts_controller.rb @@ -3,6 +3,7 @@ def show @maptiler_api_key = ENV.fetch("MAPTILER_API_KEY") @zone = zone @day = params.fetch("day", "today") + @pollutant = params.fetch("pollutant", "Total") @forecasts = CercForecastService.latest_forecasts_for(zone).data @day_forecast = forecast_for_day(@day, @forecasts) end diff --git a/app/javascript/controllers/forecast_controller.js b/app/javascript/controllers/forecast_controller.js index 8622b30c..d31a8a20 100644 --- a/app/javascript/controllers/forecast_controller.js +++ b/app/javascript/controllers/forecast_controller.js @@ -1,7 +1,7 @@ import { Controller } from "@hotwired/stimulus"; export default class ForecastController extends Controller { - static targets = ["zoneSelector", "daySelector"]; + static targets = ["zoneSelector", "daySelector", "pollutantSelector"]; changeZone() { this.updateUrl({ zone: this.zoneSelectorTarget.value }); @@ -19,6 +19,11 @@ export default class ForecastController extends Controller { this.reloadPrediction(); } + changePollutant() { + this.updateUrl({ pollutant: this.pollutantSelectorTarget.value }); + this.reloadPrediction(); + } + reloadPrediction() { // Submit the form to reload the turbo frame this.zoneSelectorTarget.form.requestSubmit(); diff --git a/app/views/forecasts/_location_selector.html.erb b/app/views/forecasts/_location_selector.html.erb index 23fd81de..8df08b64 100644 --- a/app/views/forecasts/_location_selector.html.erb +++ b/app/views/forecasts/_location_selector.html.erb @@ -1,4 +1,4 @@ -<%= form_tag(false, method: :get) do %> +<%= form_tag(false, method: :get, id: "forecast-form") do %> <%= select_tag :zone, options_for_select(Zone.all.order(:name).map(&:name), @zone.name), data: { "forecast-target": "zoneSelector", action: "change->forecast#changeZone" } diff --git a/app/views/forecasts/_pollutant_selector.html.erb b/app/views/forecasts/_pollutant_selector.html.erb index 6a8f2e6d..d48e982f 100644 --- a/app/views/forecasts/_pollutant_selector.html.erb +++ b/app/views/forecasts/_pollutant_selector.html.erb @@ -1,13 +1,12 @@ -<%= form_tag do %> - <%= select_tag :pollutant, - options_for_select({ - "All pollutants" => 'Total', - "Nitrogen Dioxide" => 'NO2', - "Particles < 2.5µm" => 'PM25', - "Particles < 10µm" => 'PM10', - "Ozone" => 'O3', - }), - class: "mt-5", - data: { action: "map#updateMap", "map-target": "pollutantSelector" } - %> -<% end %> +<%= select_tag :pollutant, +options_for_select({ + "All pollutants" => 'Total', + "Nitrogen Dioxide" => 'NO2', + "Particles < 2.5µm" => 'PM25', + "Particles < 10µm" => 'PM10', + "Ozone" => 'O3', +}, @pollutant), +class: "mt-5", +data: { action: "forecast#changePollutant", "map-target": "pollutantSelector", "forecast-target": "pollutantSelector" }, +form: "forecast-form" +%> From 19f1cfa28fe8a8a976ffbabf848ae49adb8c7b63 Mon Sep 17 00:00:00 2001 From: "joseph@dxw.com" Date: Wed, 20 Nov 2024 15:56:28 +0000 Subject: [PATCH 5/6] Store map coordinates and zoom in the URL Storing the map coordinates and zoom in the URL means we can persist them and include them in links that are shared. --- app/javascript/controllers/map_controller.js | 27 +++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/map_controller.js b/app/javascript/controllers/map_controller.js index 13b8b7d3..65443a3d 100644 --- a/app/javascript/controllers/map_controller.js +++ b/app/javascript/controllers/map_controller.js @@ -27,7 +27,20 @@ export default class MapController extends Controller { updateSettings() { const pollutant = this.pollutantSelectorTarget.value; const date = this.daySelectorTarget.querySelector(".active").dataset.date; - const newSettings = { pollutant: pollutant, date: date }; + const url = new URL(window.location.href); + const lat = parseFloat(url.searchParams.get("lat")); + const lng = parseFloat(url.searchParams.get("lng")); + const center = lat && lng ? [lat, lng] : null; + const zoom = parseInt(url.searchParams.get("zoom")); + + console.log({ pollutant, date, center, zoom }); + + const newSettings = { + pollutant: pollutant, + date: date, + center: center || this.defaultMapSettings.center, + zoom: zoom || this.defaultMapSettings.zoom, + }; this.settings = Object.assign({}, this.defaultMapSettings, newSettings); } @@ -52,6 +65,18 @@ export default class MapController extends Controller { this.map.createPane("zones"); this.addZonesLayer(); + + this.map.on("zoomend moveend", () => { + this.updateUrl(); + }); + } + + updateUrl() { + const url = new URL(window.location.href); + url.searchParams.set("lat", this.map.getCenter().lat.toFixed(6)); + url.searchParams.set("lng", this.map.getCenter().lng.toFixed(6)); + url.searchParams.set("zoom", this.map.getZoom()); + window.history.replaceState({}, "", url); } addSearchControl() { From ee0f425f77bafdd33e97ea921f293b082fff99bf Mon Sep 17 00:00:00 2001 From: "joseph@dxw.com" Date: Wed, 20 Nov 2024 17:48:41 +0000 Subject: [PATCH 6/6] Use date rather than day in the URL Currently we store the day ('today', 'tomorrow', etc.) in the URL. If the user shares the tomorrow URL on Monday and the recipient clicks on Tuesday, they will see the forecast for Wednesday. This change means we store the date ('2024-11-20') instead. If the date is outside of the bounds of the three day forecast, we fall back to the nearest day (so today for past dates and day after tomorrow for future dates). The former URLs with day will still work, but date takes precedence. --- app/controllers/forecasts_controller.rb | 15 +++++- .../controllers/forecast_controller.js | 6 +-- app/javascript/controllers/map_controller.js | 2 +- app/views/forecasts/_forecast_tabs.html.erb | 6 +-- .../forecasts/_location_selector.html.erb | 2 +- spec/controllers/forecasts_controller_spec.rb | 49 ++++++++++++++++++- 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/app/controllers/forecasts_controller.rb b/app/controllers/forecasts_controller.rb index 095e2ad7..7879696c 100644 --- a/app/controllers/forecasts_controller.rb +++ b/app/controllers/forecasts_controller.rb @@ -2,7 +2,8 @@ class ForecastsController < ApplicationController def show @maptiler_api_key = ENV.fetch("MAPTILER_API_KEY") @zone = zone - @day = params.fetch("day", "today") + @date = Date.parse(params.fetch("date")) if params[:date] + @day = @date ? day_from_date(@date) : params.fetch("day", "today") @pollutant = params.fetch("pollutant", "Total") @forecasts = CercForecastService.latest_forecasts_for(zone).data @day_forecast = forecast_for_day(@day, @forecasts) @@ -10,6 +11,18 @@ def show private + def day_from_date(date) + if date <= Date.today + "today" + elsif date == Date.tomorrow + "tomorrow" + elsif date >= Date.tomorrow + "day_after_tomorrow" + else + "today" + end + end + def forecast_for_day(day, forecasts) case day when "today" diff --git a/app/javascript/controllers/forecast_controller.js b/app/javascript/controllers/forecast_controller.js index d31a8a20..a7f2d09f 100644 --- a/app/javascript/controllers/forecast_controller.js +++ b/app/javascript/controllers/forecast_controller.js @@ -9,12 +9,12 @@ export default class ForecastController extends Controller { } changeDay(event) { - const selectedDay = event.currentTarget.dataset.day; + const selectedDate = event.currentTarget.dataset.date; - this.updateUrl({ day: selectedDay }); + this.updateUrl({ date: selectedDate }); // Update contents of daySelector - this.daySelectorTarget.value = selectedDay; + this.daySelectorTarget.value = selectedDate; this.reloadPrediction(); } diff --git a/app/javascript/controllers/map_controller.js b/app/javascript/controllers/map_controller.js index 65443a3d..fee019dc 100644 --- a/app/javascript/controllers/map_controller.js +++ b/app/javascript/controllers/map_controller.js @@ -26,7 +26,7 @@ export default class MapController extends Controller { updateSettings() { const pollutant = this.pollutantSelectorTarget.value; - const date = this.daySelectorTarget.querySelector(".active").dataset.date; + const date = this.daySelectorTarget.value; const url = new URL(window.location.href); const lat = parseFloat(url.searchParams.get("lat")); const lng = parseFloat(url.searchParams.get("lng")); diff --git a/app/views/forecasts/_forecast_tabs.html.erb b/app/views/forecasts/_forecast_tabs.html.erb index 0e3db44d..47646e71 100644 --- a/app/views/forecasts/_forecast_tabs.html.erb +++ b/app/views/forecasts/_forecast_tabs.html.erb @@ -1,9 +1,5 @@

Air pollution for <%= @zone.name %>

-
+
<%= render(DayTabComponent.new(forecast: @forecasts.first, day: 'today', active: @day == 'today')) %> <%= render(DayTabComponent.new(forecast: @forecasts.second, day: 'tomorrow', active: @day == 'tomorrow')) %> <%= render(DayTabComponent.new(forecast: @forecasts.third, day: 'day_after_tomorrow', active: @day == 'day_after_tomorrow')) %> diff --git a/app/views/forecasts/_location_selector.html.erb b/app/views/forecasts/_location_selector.html.erb index 8df08b64..27b685ec 100644 --- a/app/views/forecasts/_location_selector.html.erb +++ b/app/views/forecasts/_location_selector.html.erb @@ -3,5 +3,5 @@ options_for_select(Zone.all.order(:name).map(&:name), @zone.name), data: { "forecast-target": "zoneSelector", action: "change->forecast#changeZone" } %> - <%= hidden_field_tag :day, @day, data: { "forecast-target": "daySelector" } %> + <%= hidden_field_tag :date, @date, data: { "forecast-target": "daySelector", "map-target": "daySelector" } %> <% end %> diff --git a/spec/controllers/forecasts_controller_spec.rb b/spec/controllers/forecasts_controller_spec.rb index 6c1f025f..bb5a00e0 100644 --- a/spec/controllers/forecasts_controller_spec.rb +++ b/spec/controllers/forecasts_controller_spec.rb @@ -41,7 +41,7 @@ end context "when a recognised _day_ parameter is received" do - it "renders the turbo update template" do + it "renders the _show_ template" do allow(CercForecastService).to receive(:latest_forecasts_for).and_return(forecasts) get :show, params: {day: :today} @@ -60,5 +60,52 @@ }.to raise_error(ArgumentError, "Invalid day: yesterday") end end + + context "when a _date_ parameter is received" do + before do + allow(CercForecastService).to receive(:latest_forecasts_for).and_return(forecasts) + get :show, params: {date: date.to_s} + end + + context "when the date is in the past" do + let(:date) { 5.day.ago.to_date } + + it "shows the forecast for today" do + expect(assigns(:day_forecast)).to eq(forecasts.data.first) + end + end + + context "when the date is today" do + let(:date) { Date.today } + + it "shows the forecast for today" do + expect(assigns(:day_forecast)).to eq(forecasts.data.first) + end + end + + context "when the date is tomorrow" do + let(:date) { Date.tomorrow } + + it "shows the forecast for tomorrow" do + expect(assigns(:day_forecast)).to eq(forecasts.data.second) + end + end + + context "when the date is the day after tomorrow" do + let(:date) { 2.days.from_now.to_date } + + it "shows the forecast for the day after tomorrow" do + expect(assigns(:day_forecast)).to eq(forecasts.data.third) + end + end + + context "when the date is in the future" do + let(:date) { 5.days.from_now.to_date } + + it "shows the forecast for the day after tomorrow" do + expect(assigns(:day_forecast)).to eq(forecasts.data.third) + end + end + end end end