DEV Community

Cover image for How to Develop an Air Quality Monitoring App with Ruby on Rails
Eazybright😊😊
Eazybright😊😊

Posted on

How to Develop an Air Quality Monitoring App with Ruby on Rails

Prerequisites:

  • Ruby
  • sqlite3
  • Rails Installer

Install the app

rails new air_quality_app
Enter fullscreen mode Exit fullscreen mode

serve the application

cd air_quality_app

bin/rails server
Enter fullscreen mode Exit fullscreen mode

visit your browser with this link http://127.0.0.1:3000, you should have a similar display as below
Rails default information page

Create a controller

To create FeedsController and its index action, run the controller generator in the terminal.

bin/rails generate controller Feeds index 
Enter fullscreen mode Exit fullscreen mode

install the Waqi Ruby SDK

Add this line to your application's Gemfile:

gem 'waqi-ruby'
Enter fullscreen mode Exit fullscreen mode

And then execute:

bundle
Enter fullscreen mode Exit fullscreen mode

Or install it yourself as:

gem install waqi-ruby
Enter fullscreen mode Exit fullscreen mode

You need a token to conveniently access the Air QUality Open Data Platform API. Visit https://aqicn.org/data-platform/token/ to get a token.

Open your terminal to save the token

EDITOR=vim rails credentials:edit
Enter fullscreen mode Exit fullscreen mode

You can change the editor to your preferred choice like nano.

Add the following line and save the file.

air_quality_data_token: <insert-the-api-token-here>
Enter fullscreen mode Exit fullscreen mode

You should be able to access the token using the code: Rails.application.credentials[:air_quality_data_token]

Add some styling to the view

Use tailwind css for the styling. A tailwind ruby gem is available at https://github.com/rails/tailwindcss-rails, you can check it out for more details.

install tailwindcss

bundle add tailwindcss-rails
rails tailwindcss:install
Enter fullscreen mode Exit fullscreen mode

The above commands will create files and folders necessary to kickstart designing with tailwind: app/views/layouts/application.html.erb, app/assets/builds/, config/tailwind.config.js, app/assets/stylesheets/application.tailwind.css, Procfile.dev, bin/dev.

Additionally, you can install foreman gem to run Tailwind in "watch" mode concurrently. Style changes are automatically reflected in the generated CSS output.

To configure tailwind to be applied in all template files, you can edit config/tailwind.config.js file.

Now serve the application: bin/dev
This will run both the rail server and tailwind watch.

Adjust your application layout to use nice UI. Update the body section of app/views/layouts/application.html.erb file with the code below:

...
<body class="flex flex-col justify-between">

    <header class="bg-gray-500 text-white shadow-lg">
        <nav class="mx-auto flex max-w-7xl items-center justify-between gap-x-6 p-6 lg:px-8" aria-label="Global">
            <h1 class="font-bold md:text-2xl m-0">
                WAQI Essentials
            </h1>
                <div class="flex flex-1 items-center justify-end gap-x-6">
                <p class="text-white text-sm font-semibold leading-6">City Feed</p>
                <p class="text-white text-sm font-semibold leading-6">Search</p>
            </div>
        </nav>
    </header>

    <main>
        <div class="max-w-7xl mx-auto p-6 lg:px-8 prose">
            <%= yield %>
        </div>
    </main>
</body>
...
Enter fullscreen mode Exit fullscreen mode

Edit app/views/feeds/index.html.erb to use the parent layout that has the tailwind file configured.

<div class="text-center">
    <h1 class="text-3xl font-bold">City Feed</h1>
    <p>Get real-time Air Quality index for a given station.</p>
</div>
<form>   
    <label for="default-search" class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-gray-300">Search</label>
    <div class="relative">
        <div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
            <svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
        </div>
        <input type="search" id="default-search" class="block p-4 pl-10 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="enter name of city..." required>
        <button type="submit" class="text-white absolute right-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Search</button>
    </div>
</form>
Enter fullscreen mode Exit fullscreen mode

You should have a result as below:

feeds index page with form field

Let's interact with api by submitting a city name and get a result.
First, modify the form tag to conform to rails code standard!

...
<%= form_with url: "/feeds/index", method: :get do |form| %>   
    <%= form.label :city, "Search", class:"mb-2 text-sm font-medium text-gray-900 sr-only dark:text-gray-300" %>
    <div class="relative">
        <div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
            <svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
        </div>

        <%= form.text_field :city, placeholder: "enter name of city...", autofocus: true, required: true, value: @city,
            class:"block p-4 pl-10 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
        %>

        <%= form.submit "Search", class: "text-white absolute right-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800" %>
    </div>
<% end %>

<div class="text-center">
    <p><%= @feeds %></p>
</div>
Enter fullscreen mode Exit fullscreen mode

You should notice the form_url tag has the url '/feeds/index'. After submitting the form the request goes to the index action on feeds_controller.

Modify the controller to accept the request and connect to Air Quality API to get a result:

# app/controllers/feeds_controller.rb

require 'waqi'

class FeedsController < ApplicationController
    before_action :set_client

    def index
        @city = request.query_parameters['city']

        if !@city.blank?
            @feeds = @waqi_client.get_city_feed(@city)
        end
    end

    private 

    def set_client
        @waqi_client = Waqi::Client::new(api_key: Rails.application.credentials[:air_quality_data_token])
    end
end
Enter fullscreen mode Exit fullscreen mode

The above code does the following:

  1. Loads the https://github.com/waqi-dev-community/waqi-ruby-client at the top of the file.
  2. A before_action ruby method initialize set_client method. This method allows us to use the library in the code.
  3. Index method accepts the query parameter city to be used.

When you search for a city let's say new-york, you should get this result in your browser:

output raw result for city search

You can beautify your result and integrate a Google line chart to display daily forecast for Ozone and Particulate matter level:

Add a script tag to app/views/layouts/application.html.erb file

...

<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
    <script type="text/javascript">
        google.charts.load('current', {'packages':['line']});
        var forecast = document.getElementById('line_top_x').getAttribute('data-forecast')
        if(forecast.length) {
            google.charts.setOnLoadCallback(drawChart);
        }

        function drawChart() {
            var data = new google.visualization.DataTable();
            data.addColumn('string', 'Date');
            data.addColumn('number', 'Ozone (o3)');
            data.addColumn('number', 'Particulate Matter 10 (pm10)');
            data.addColumn('number', 'Particulate Matter 25 (pm25)');
            data.addRows(JSON.parse(forecast));

            var options = {
                width: 900,
                height: 500,
                axes: {
                    x: {
                        0: {side: 'top'}
                    }
                }
            };

            var chart = new google.charts.Line(document.getElementById('line_top_x'));

            chart.draw(data, google.charts.Line.convertOptions(options));
        }
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

The script loads the chart library and the forecast data is inserted by getting the data attribute on the page app/views/feeds/index.html.erb:

var forecast = document.getElementById('line_top_x').getAttribute('data-forecast')
Enter fullscreen mode Exit fullscreen mode

Insert this code snippet below into the city_feed views page:

<% if @isError %>
    <div class="m-4 p-2 text-red-500">
        <span>Error: <%= @response["data"] %></span>
    </div>
<% else %>

    <div class="text-center grid grid-flow-row-dense grid-cols-4 grid-rows-4">

        <p><span class="font-black">City: </span> <%= @city_info["name"] %></p>
        <p><span class="font-black">Latitude: </span><%= @city_info["geo"][0] %></p>
        <p><span class="font-black">Longitude: </span><%= @city_info["geo"][1] %></p>

        <% if !@iaqi.blank? %>
            <% @iaqi.each do |iaqi|  %>
                <% extra = determineLabel(iaqi["name"]).to_h %>
                <p><span class="font-black"><%= extra["label"] %>: </span><%= iaqi["value"] %> <%= extra["unit"] %></p>
            <% end %>
        <% end %>
    </div>

    <div class="text-center">
        <h3> Daily forecast for Ozone, Particulate Matter 10 and 25</h3>
        <div id="line_top_x" style="margin-right: auto;margin-left: auto;display: table;" data-forecast="<%= @daily_forecast %>"></div>
    </div>

<% end %>
Enter fullscreen mode Exit fullscreen mode

The code above handles the error if there's any and also display the city, latitude, longitude, and other air quality information for a particular station.

This line of code <% extra = determineLabel(iaqi["name"]).to_h %> is used to format the data with readable texts and provide more information to the user. You can update the app/helpers/application_helper.rb with the code below. This is a helper class.

module ApplicationHelper
    def determineLabel(name)
        case name
        when "co"
            { "label" => "Carbon monoxide", "unit" => "ppm" }
        when "no2"
            { "label" => "Nitrogen dioxide", "unit" => "ppb" }
        when "pm10"
            { "label" => "PM10", "unit" => "µg/m³" }
        when "pm25"
            { "label" => "PM25", "unit" => "µg/m³" }
        when "o3"
            { "label" => "Ozone", "unit" => "ppb" }
        when "w"
            { "label" => "Wind Speed", "unit" => "m/s" }
        when "t"
            { "label" => "Temperature", "unit" => "°C" }
        when "p"
            { "label" => "Air pressure", "unit" => "mb" }
        when "so2"
            { "label" => "Sulfur dioxide", "unit" => "ppb" }
        when "h"
            { "label" => "Humidity", "unit" => "%"} 
        when "dew"
            { "label" => "Dew", "unit" => "°C"}
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

Update the index method on FeedsController file:

def index
        @city = request.query_parameters['city']
        if !@city.blank?
            response = @waqi_client.get_city_feed(@city)

            if response["status"] == "ok"
                @response = response["data"]
                @city_info = response["data"]["city"]

                @iaqi = response["data"]["iaqi"].map do |key, value|
                      { "name" => key.to_s, "value" => value["v"] } 
                end

                @forecast = response["data"]["forecast"]["daily"]

                @daily_forecast = format_forecast_data(@forecast)

            elsif response["status"] == "error"
                @isError = true
                @response = response
            end
        end
    end

private 

... 

def format_forecast_data(air_quality_data)
        # Extract days from "o3", "pm10", and "pm25" arrays
        o3_days = air_quality_data["o3"].map { |data| data["day"] } || []
        pm10_days = air_quality_data["pm10"].map { |data| data["day"] } || []
        pm25_days = air_quality_data["pm25"].map { |data| data["day"] } || []

        # Find intersection of the three arrays
        identical_days = o3_days & pm10_days & pm25_days
        formatted_parameter_data = []

          # Extract required values and push into new array
        identical_days.each do |date|
            o3_index = air_quality_data["o3"].find_index { |entry| entry["day"] == date }
            pm10_index = air_quality_data["pm10"].find_index { |entry| entry["day"] == date }
            pm25_index = air_quality_data["pm25"].find_index { |entry| entry["day"] == date }

            formatted_parameter_data << [date, air_quality_data["o3"][o3_index]["avg"], air_quality_data["pm10"][pm10_index]["avg"], air_quality_data["pm25"][pm25_index]["avg"]]
        end
        formatted_parameter_data
    end
Enter fullscreen mode Exit fullscreen mode

Image showing the result for abuja station with a display of line chart

You can explore more functionality with the ruby sdk such as Lat/Lng based Geolocalized feed, IP based Geolocalized feed, etc.

The complete source code can be found on github

Top comments (0)