├── .rspec ├── lib ├── kovid │ ├── version.rb │ ├── uri_builder.rb │ ├── cache.rb │ ├── painter.rb │ ├── helpers.rb │ ├── aggregators.rb │ ├── historians.rb │ ├── cli.rb │ ├── constants.rb │ ├── ascii_charts.rb │ ├── tablelize.rb │ └── request.rb └── kovid.rb ├── exe ├── covid └── kovid ├── .travis.yml ├── .rubocop.yml ├── bin ├── setup └── console ├── .gitignore ├── Gemfile ├── Rakefile ├── spec ├── kovid_uri_builder_spec.rb ├── spec_helper.rb └── kovid_spec.rb ├── LICENSE.txt ├── Gemfile.lock ├── .rubocop_todo.yml ├── kovid.gemspec ├── CODE_OF_CONDUCT.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/kovid/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kovid 4 | VERSION = '0.7.1' 5 | end 6 | -------------------------------------------------------------------------------- /exe/covid: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'kovid/cli' 5 | 6 | Kovid::CLI.start 7 | -------------------------------------------------------------------------------- /exe/kovid: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'kovid/cli' 5 | 6 | Kovid::CLI.start 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | cache: bundler 4 | rvm: 5 | - 2.7.0 6 | before_install: gem install bundler -v 2.1.2 7 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 2.6 5 | 6 | Style/Documentation: 7 | Enabled: false -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in kovid.gemspec 6 | gemspec 7 | 8 | gem 'rake', '~> 12.0' 9 | gem 'rspec', '~> 3.0' 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | # rubocop:disable Style/HashSyntax 9 | task :default => :spec 10 | # rubocop:enable Style/HashSyntax 11 | -------------------------------------------------------------------------------- /spec/kovid_uri_builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Kovid::UriBuilder do 4 | it 'returns a string URL given a path' do 5 | builder = Kovid::UriBuilder.new('/hello').url 6 | 7 | expect(builder).to eq('https://corona.lmao.ninja/hello') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/kovid/uri_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 4 | 5 | module Kovid 6 | class UriBuilder 7 | attr_reader :path 8 | 9 | BASE_URI = 'corona.lmao.ninja' 10 | 11 | def initialize(path = '') 12 | @path = path 13 | end 14 | 15 | def url 16 | URI::HTTPS.build(host: BASE_URI, path: path).to_s 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/kovid/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'typhoeus' 4 | module Kovid 5 | class Cache 6 | def initialize 7 | @memory = {} 8 | end 9 | 10 | def get(request) 11 | @memory[request] 12 | end 13 | 14 | def set(request, response) 15 | @memory[request] = response 16 | end 17 | end 18 | 19 | Typhoeus::Config.cache = Cache.new 20 | end 21 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'kovid' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /lib/kovid/painter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rainbow' 4 | class String 5 | def paint_white 6 | Rainbow(self).white.bg(:black).bold 7 | end 8 | 9 | def paint_red 10 | Rainbow(self).red.bg(:black).bold 11 | end 12 | 13 | def paint_green 14 | Rainbow(self).green.bg(:black).bold 15 | end 16 | 17 | def paint_yellow 18 | Rainbow(self).yellow.bg(:black).bold 19 | end 20 | 21 | def paint_highlight 22 | Rainbow(self).underline 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start do 5 | add_filter 'lib/kovid/ascii_charts.rb' 6 | end 7 | 8 | require 'bundler/setup' 9 | require 'kovid' 10 | 11 | RSpec.configure do |config| 12 | # Enable flags like --only-failures and --next-failure 13 | config.example_status_persistence_file_path = '.rspec_status' 14 | 15 | # Disable RSpec exposing methods globally on `Module` and `main` 16 | config.disable_monkey_patching! 17 | 18 | config.expect_with :rspec do |c| 19 | c.syntax = :expect 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/kovid/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'terminal-table' 4 | 5 | module Kovid 6 | module_function 7 | 8 | def info_table(message) 9 | rows = [[message.to_s]] 10 | puts Terminal::Table.new title: '❗️', rows: rows 11 | end 12 | 13 | # Parse date as "02 Apr, 20" 14 | def dateman(date) 15 | date_to_parse = Date.strptime(date, '%m/%d/%y').to_s 16 | Date.parse(date_to_parse).strftime('%d %b, %y') 17 | end 18 | 19 | def comma_delimit(number) 20 | number.to_s.chars.to_a.reverse.each_slice(3).map(&:join).join(',').reverse 21 | end 22 | 23 | # Insert + sign to format positive numbers 24 | def add_plus_sign(num) 25 | num.to_i.positive? ? "+#{comma_delimit(num)}" : comma_delimit(num).to_s 26 | end 27 | 28 | def lookup_us_state(state) 29 | Kovid::Constants::USA_ABBREVIATIONS.fetch(state.downcase, state) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/kovid/aggregators.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kovid 4 | module Aggregators 5 | def eu_aggregate(eu_data) 6 | aggregated_table(eu_data, 'The EU', Kovid::Request::EU_ISOS, '🇪🇺') 7 | end 8 | 9 | def europe_aggregate(europe_data) 10 | aggregated_table(europe_data, 'Europe', Kovid::Request::EUROPE_ISOS, '🏰') 11 | end 12 | 13 | def africa_aggregate(africa_data) 14 | aggregated_table(africa_data, 'Africa', 15 | Kovid::Request::AFRICA_ISOS, '🌍') 16 | end 17 | 18 | def south_america_aggregate(south_america_data) 19 | aggregated_table(south_america_data, 20 | 'South America', 21 | Kovid::Request::SOUTH_AMERICA_ISOS, '🌎') 22 | end 23 | 24 | def asia_aggregate(asia_data) 25 | aggregated_table(asia_data, 'Asia', Kovid::Request::ASIA_ISOS, '🌏') 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Emmanuel Hayford 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | kovid (0.7.0) 5 | rainbow (~> 3.0) 6 | terminal-table (~> 1.8) 7 | thor (~> 1.0) 8 | typhoeus (~> 1.3) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | diff-lcs (1.3) 14 | docile (1.3.2) 15 | ethon (0.15.0) 16 | ffi (>= 1.15.0) 17 | ffi (1.15.5) 18 | rainbow (3.1.1) 19 | rake (12.3.3) 20 | rspec (3.9.0) 21 | rspec-core (~> 3.9.0) 22 | rspec-expectations (~> 3.9.0) 23 | rspec-mocks (~> 3.9.0) 24 | rspec-core (3.9.2) 25 | rspec-support (~> 3.9.3) 26 | rspec-expectations (3.9.2) 27 | diff-lcs (>= 1.2.0, < 2.0) 28 | rspec-support (~> 3.9.0) 29 | rspec-mocks (3.9.1) 30 | diff-lcs (>= 1.2.0, < 2.0) 31 | rspec-support (~> 3.9.0) 32 | rspec-support (3.9.3) 33 | simplecov (0.18.5) 34 | docile (~> 1.1) 35 | simplecov-html (~> 0.11) 36 | simplecov-html (0.12.2) 37 | terminal-table (1.8.0) 38 | unicode-display_width (~> 1.1, >= 1.1.1) 39 | thor (1.2.1) 40 | typhoeus (1.4.0) 41 | ethon (>= 0.9.0) 42 | unicode-display_width (1.8.0) 43 | 44 | PLATFORMS 45 | ruby 46 | 47 | DEPENDENCIES 48 | kovid! 49 | rake (~> 12.0) 50 | rspec (~> 3.0) 51 | simplecov (~> 0.18) 52 | 53 | BUNDLED WITH 54 | 2.3.9 55 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2020-04-07 20:38:28 -0300 using RuboCop version 0.67.2. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 2 10 | Metrics/AbcSize: 11 | Max: 54 12 | Exclude: 13 | - 'lib/kovid/historians.rb' 14 | 15 | # Offense count: 2 16 | Metrics/BlockLength: 17 | Exclude: 18 | - 'kovid.gemspec' 19 | - 'spec/kovid_spec.rb' 20 | 21 | # Offense count: 3 22 | # Configuration parameters: CountComments. 23 | Metrics/ClassLength: 24 | Max: 220 25 | Exclude: 26 | - 'lib/kovid/cli.rb' 27 | - 'lib/kovid/request.rb' 28 | - 'lib/kovid/tablelize.rb' 29 | 30 | # Offense count: 2 31 | # Configuration parameters: CountComments, ExcludedMethods. 32 | Metrics/MethodLength: 33 | Max: 44 34 | Exclude: 35 | - 'lib/kovid/historians.rb' 36 | 37 | # Offense count: 1 38 | Metrics/PerceivedComplexity: 39 | Max: 8 40 | Exclude: 41 | - 'lib/kovid/historians.rb' 42 | 43 | # Offense count: 1 44 | Style/CaseEquality: 45 | Exclude: 46 | - 'lib/kovid/request.rb' 47 | 48 | Style/Documentation: 49 | Exclude: 50 | - 'spec/**/*' 51 | - 'test/**/*' 52 | - 'lib/kovid.rb' 53 | - 'lib/kovid/helpers.rb' 54 | 55 | # Offense count: 5 56 | Style/MultilineBlockChain: 57 | Exclude: 58 | - 'lib/kovid/historians.rb' 59 | - 'lib/kovid/request.rb' 60 | -------------------------------------------------------------------------------- /kovid.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/kovid/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'kovid' 7 | spec.version = Kovid::VERSION 8 | spec.authors = ['Emmanuel Hayford'] 9 | spec.email = ['siawmensah@gmail.com'] 10 | 11 | summary = 'A CLI to fetch and compare the 2019 ' \ 12 | 'coronavirus pandemic statistics.' 13 | spec.summary = summary 14 | spec.description = summary 15 | spec.homepage = 'https://github.com/siaw23/kovid' 16 | spec.license = 'MIT' 17 | spec.required_ruby_version = Gem::Requirement.new('>= 2.7.0') 18 | 19 | spec.metadata['allowed_push_host'] = 'https://rubygems.org/' 20 | 21 | spec.metadata['homepage_uri'] = spec.homepage 22 | spec.metadata['source_code_uri'] = 'https://github.com/siaw23/kovid' 23 | spec.metadata['changelog_uri'] = 'https://github.com/siaw23/kovid' 24 | 25 | spec.add_dependency 'rainbow', '~> 3.0' 26 | spec.add_dependency 'terminal-table', '~> 1.8' 27 | spec.add_dependency 'thor', '~> 1.0' 28 | spec.add_dependency 'typhoeus', '~> 1.3' 29 | spec.add_development_dependency 'simplecov', '~> 0.18' 30 | 31 | # Specify which files should be added to the gem when it is released. 32 | # The `git ls-files -z` loads the files in the RubyGem 33 | # that have been added into git. 34 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 35 | `git ls-files -z`.split("\x0").reject do |f| 36 | f.match(%r{^(test|spec|features)/}) 37 | end 38 | end 39 | spec.bindir = 'exe' 40 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 41 | spec.require_paths = ['lib'] 42 | end 43 | -------------------------------------------------------------------------------- /lib/kovid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'kovid/version' 4 | require 'kovid/request' 5 | 6 | module Kovid 7 | require 'kovid/helpers' 8 | 9 | module_function 10 | 11 | def eu_aggregate 12 | Kovid::Request.eu_aggregate 13 | end 14 | 15 | def europe_aggregate 16 | Kovid::Request.europe_aggregate 17 | end 18 | 19 | def africa_aggregate 20 | Kovid::Request.africa_aggregate 21 | end 22 | 23 | def south_america_aggregate 24 | Kovid::Request.south_america_aggregate 25 | end 26 | 27 | def asia_aggregate 28 | Kovid::Request.asia_aggregate 29 | end 30 | 31 | def country(name) 32 | Kovid::Request.by_country(name) 33 | end 34 | 35 | def country_full(name) 36 | Kovid::Request.by_country_full(name) 37 | end 38 | 39 | def province(name) 40 | Kovid::Request.province(name) 41 | end 42 | 43 | def provinces(names) 44 | Kovid::Request.provinces(names) 45 | end 46 | 47 | def state(state) 48 | Kovid::Request.state(state) 49 | end 50 | 51 | def states(states) 52 | Kovid::Request.states(states) 53 | end 54 | 55 | def all_us_states 56 | Kovid::Request.all_us_states 57 | end 58 | 59 | def country_comparison(names_array) 60 | Kovid::Request.by_country_comparison(names_array) 61 | end 62 | 63 | def country_comparison_full(names_array) 64 | Kovid::Request.by_country_comparison_full(names_array) 65 | end 66 | 67 | def cases 68 | Kovid::Request.cases 69 | end 70 | 71 | def history(country, days = 30) 72 | Kovid::Request.history(country, days) 73 | end 74 | 75 | def history_us_state(state, days = 30) 76 | Kovid::Request.history_us_state(state, days) 77 | end 78 | 79 | def histogram(country, date) 80 | Kovid::Request.histogram(country, date) 81 | end 82 | 83 | def top(count, options = { location: :countries, incident: :cases }) 84 | Kovid::Request.top(count, options) 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at siawmensah@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/kovid/historians.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kovid 4 | module Historians 5 | include Constants 6 | include AsciiCharts 7 | 8 | def history(data, days) 9 | rows = history_rows(data, days) 10 | 11 | if rows.size > ADD_FOOTER_SIZE 12 | rows << FOOTER_LINE_FOUR_COLUMNS 13 | rows << DATE_CASES_DEATHS_RECOVERED 14 | end 15 | 16 | Terminal::Table.new( 17 | title: data['country']&.upcase, 18 | headings: DATE_CASES_DEATHS_RECOVERED, 19 | rows: rows 20 | ) 21 | end 22 | 23 | def history_us_state(data, days) 24 | rows = history_rows(data, days) 25 | 26 | if rows.size > ADD_FOOTER_SIZE 27 | rows << FOOTER_LINE_THREE_COLUMNS 28 | rows << DATE_CASES_DEATHS 29 | end 30 | 31 | Terminal::Table.new( 32 | title: data['state']&.upcase, 33 | headings: DATE_CASES_DEATHS, 34 | rows: rows 35 | ) 36 | end 37 | 38 | def histogram(country, date_string) 39 | @date = date_string.split('.') 40 | 41 | if @date.last.to_i != 20 42 | Kovid.info_table('Only 2020 histgrams are available.') 43 | return 44 | end 45 | 46 | # From dates where number of !cases.zero? 47 | country_cases = country['timeline']['cases'] 48 | positive_cases_figures = country_cases.values.reject(&:zero?) 49 | dates = country_cases.reject { |_k, v| v.zero? }.keys 50 | data = [] 51 | 52 | # TODO: Refactor 53 | # Returns array of days.to_i from the date param 54 | dates = dates.map do |date| 55 | date.split('/') 56 | end.select do |date| 57 | date.last == @date.last 58 | end.select do |date| 59 | date.first == @date.first 60 | end.map do |array| 61 | array[1] 62 | end.map(&:to_i).last(positive_cases_figures.count) 63 | 64 | # Arranges dates and figures in [x,y] for histogram 65 | # With x being day, y being number of cases 66 | if dates.empty? 67 | if @date.first.to_i > Time.now.month 68 | msgs = [ 69 | 'Seriously...??! 😏', 'Did you just check the future??', 70 | 'You just checked the future Morgan.', 71 | 'Knowing too much of your future is never a good thing.' 72 | ] 73 | 74 | Kovid.info_table(msgs.sample) 75 | else 76 | Kovid.info_table('Check your spelling/No infections for this month.') 77 | end 78 | 79 | else 80 | dates.each_with_index do |val, index| 81 | data << [val, positive_cases_figures[index]] 82 | end 83 | y_range = Kovid::AsciiCharts::Cartesian.new( 84 | data, bar: true, hide_zero: true 85 | ).y_range 86 | 87 | last_two_y = y_range.last 2 88 | y_interval = last_two_y.last - last_two_y.first 89 | 90 | scale("Scale on Y: #{y_interval}:#{( 91 | y_interval / last_two_y.last.to_f * positive_cases_figures.last 92 | ).round(2) / y_interval}") 93 | 94 | puts 'Experimental feature, please report issues.' 95 | 96 | AsciiCharts::Cartesian.new(data, bar: true, hide_zero: true).draw 97 | end 98 | end 99 | 100 | private 101 | 102 | def history_rows(data, days) 103 | data['timeline']['cases'].map do |date, cases| 104 | formatted_date = Kovid.dateman(date) 105 | cases = Kovid.comma_delimit(cases) 106 | deaths = Kovid.comma_delimit(data['timeline']['deaths'][date]) 107 | recovered = Kovid.comma_delimit(data['timeline']['recovered'][date]) if data['timeline'].has_key? 'recovered' 108 | 109 | row = [formatted_date, cases, deaths] 110 | row << recovered unless recovered.nil? 111 | row 112 | end.last(days.to_i) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/kovid/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'thor' 4 | require 'kovid' 5 | 6 | module Kovid 7 | class CLI < Thor 8 | def self.exit_on_failure? 9 | true 10 | end 11 | 12 | desc 'province PROVINCE or province "PROVINCE NAME"', 13 | 'Returns reported data on provided province. ' \ 14 | 'eg "kovid check "new brunswick".' 15 | method_option :full, aliases: '-p' 16 | def province(name) 17 | puts Kovid.province(name) 18 | data_source 19 | end 20 | 21 | desc 'provinces PROVINCE PROVINCE', 22 | 'Returns full comparison table for the given provinces. ' \ 23 | 'Accepts multiple provinces.' 24 | def provinces(*names) 25 | puts Kovid.provinces(names) 26 | data_source 27 | end 28 | 29 | desc 'check COUNTRY or check "COUNTRY NAME"', 30 | 'Returns reported data on provided country. ' \ 31 | 'eg: "kovid check "hong kong".' 32 | method_option :full, aliases: '-f' 33 | def check(*name) 34 | if name.size == 1 35 | fetch_country_stats(name.pop) 36 | elsif options[:full] 37 | puts Kovid.country_comparison_full(name) 38 | else 39 | puts Kovid.country_comparison(name) 40 | end 41 | data_source 42 | end 43 | map country: :check 44 | 45 | desc 'compare COUNTRY COUNTRY', 'Deprecated. Will be removed in v7.0.0' 46 | def compare(*_name) 47 | Kovid.info_table("#compare is deprecated and will be removed in v7.0.0. \ 48 | \nPlease do `kovid check COUNTRY COUNTRY ...` instead.") 49 | end 50 | 51 | desc 'state STATE', 'Return reported data on provided state.' 52 | def state(state) 53 | puts Kovid.state(state) 54 | data_source 55 | end 56 | 57 | desc 'states STATE STATE or states --all', 58 | 'Returns full comparison table for the given states. ' \ 59 | 'Accepts multiple states.' 60 | method_option :all, aliases: '-a' 61 | def states(*states) 62 | if options[:all] 63 | puts Kovid.all_us_states 64 | else 65 | downcased_states = states.map(&:downcase) 66 | puts Kovid.states(downcased_states) 67 | end 68 | 69 | data_source 70 | end 71 | 72 | desc 'world', 'Returns total number of cases, deaths and recoveries.' 73 | def world 74 | puts Kovid.cases 75 | data_source 76 | end 77 | 78 | desc 'history COUNTRY or history COUNTRY N or' \ 79 | 'history STATE --usa', 80 | 'Return history of incidents of COUNTRY|STATE (in the last N days)' 81 | option :usa, type: :boolean 82 | def history(location, days = 30) 83 | if options[:usa] 84 | puts Kovid.history_us_state(location, days) 85 | else 86 | puts Kovid.history(location, days) 87 | end 88 | data_source 89 | end 90 | 91 | desc 'eu', 'Returns aggregated data on the EU.' 92 | def eu 93 | puts Kovid.eu_aggregate 94 | data_source 95 | end 96 | 97 | desc 'europe', 'Returns aggregated data on Europe.' 98 | def europe 99 | puts Kovid.europe_aggregate 100 | data_source 101 | end 102 | 103 | desc 'africa', 'Returns aggregated data on Africa.' 104 | def africa 105 | puts Kovid.africa_aggregate 106 | data_source 107 | end 108 | 109 | desc 'sa', 'Returns aggregated data on South America.' 110 | def sa 111 | puts Kovid.south_america_aggregate 112 | data_source 113 | end 114 | 115 | desc 'asia', 'Returns aggregated data on Asia.' 116 | def asia 117 | puts Kovid.asia_aggregate 118 | data_source 119 | end 120 | 121 | desc 'version', 'Returns version of kovid' 122 | def version 123 | puts Kovid::VERSION 124 | end 125 | 126 | desc 'histogram COUNTRY M.YY', 'Returns a histogram of incidents.' 127 | def histogram(country, date = nil) 128 | if date.nil? 129 | Kovid.info_table("Please add a month and year in the form 'M.YY'") 130 | else 131 | puts Kovid.histogram(country, date) 132 | data_source 133 | end 134 | end 135 | 136 | desc 'top N', 137 | 'Returns top N countries or states in an incident (number of cases or 138 | deaths).' 139 | method_option :countries 140 | method_option :states 141 | method_option :cases, aliases: '-c' 142 | method_option :deaths, aliases: '-d' 143 | def top(count = 5) 144 | count = count.to_i 145 | count = 5 if count.zero? 146 | puts Kovid.top(count, prepare_top_params(options)) 147 | data_source 148 | end 149 | 150 | private 151 | 152 | def fetch_country_stats(country) 153 | if options[:full] 154 | puts Kovid.country_full(country) 155 | else 156 | puts Kovid.country(country) 157 | end 158 | end 159 | 160 | def data_source 161 | source = <<~TEXT 162 | #{Time.now} 163 | Sources: 164 | * worldometers.info/coronavirus 165 | * Johns Hopkins University 166 | TEXT 167 | puts source 168 | end 169 | 170 | def prepare_top_params(options) 171 | params = { 172 | location: :countries, 173 | incident: :cases 174 | } 175 | 176 | if !options[:states].nil? && options[:countries].nil? 177 | params[:location] = :states 178 | end 179 | 180 | if !options[:deaths].nil? && options[:cases].nil? 181 | params[:incident] = :deaths 182 | end 183 | params 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/kovid/constants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kovid 4 | module Constants 5 | CASES_DEATHS_RECOVERED = [ 6 | 'Cases'.paint_white, 7 | 'Deaths'.paint_red, 8 | 'Recovered'.paint_green 9 | ].freeze 10 | 11 | CASES_DEATHS_RECOVERED_CTODAY_DTODAY = [ 12 | 'Cases'.paint_white, 13 | 'Cases Today'.paint_white, 14 | 'Deaths'.paint_red, 15 | 'Deaths Today'.paint_red, 16 | 'Active'.paint_yellow, 17 | 'Recovered'.paint_green, 18 | 'Tests'.paint_white 19 | ].freeze 20 | 21 | DATE_CASES_DEATHS_RECOVERED = [ 22 | 'Date'.paint_white, 23 | 'Cases'.paint_white, 24 | 'Deaths'.paint_red, 25 | 'Recovered'.paint_green 26 | ].freeze 27 | 28 | DATE_CASES_DEATHS = [ 29 | 'Date'.paint_white, 30 | 'Cases'.paint_white, 31 | 'Deaths'.paint_red 32 | ].freeze 33 | 34 | CONTINENTAL_AGGREGATE_HEADINGS = [ 35 | 'Cases'.paint_white, 36 | 'Cases Today'.paint_white, 37 | 'Deaths'.paint_red, 38 | 'Deaths Today'.paint_red, 39 | 'Recovered'.paint_green, 40 | 'Active'.paint_yellow, 41 | 'Critical'.paint_red 42 | ].freeze 43 | 44 | COMPARE_COUNTRY_TABLE_FULL = [ 45 | 'Country'.paint_white, 46 | 'Cases'.paint_white, 47 | 'Cases Today'.paint_white, 48 | 'Deaths'.paint_red, 49 | 'Deaths Today'.paint_red, 50 | 'Active'.paint_yellow, 51 | 'Recovered'.paint_green, 52 | 'Critical'.paint_yellow, 53 | 'Tests'.paint_white, 54 | 'Cases/Million'.paint_white 55 | ].freeze 56 | 57 | COMPARE_COUNTRIES_TABLE_HEADINGS = [ 58 | 'Country'.paint_white, 59 | 'Cases'.paint_white, 60 | 'Cases Today'.paint_white, 61 | 'Deaths'.paint_red, 62 | 'Deaths Today'.paint_red, 63 | 'Active'.paint_yellow, 64 | 'Recovered'.paint_green, 65 | 'Tests'.paint_white 66 | ].freeze 67 | 68 | FULL_COUNTRY_TABLE_HEADINGS = [ 69 | 'Cases'.paint_white, 70 | 'Cases Today'.paint_white, 71 | 'Deaths'.paint_red, 72 | 'Deaths Today'.paint_red, 73 | 'Active'.paint_yellow, 74 | 'Recovered'.paint_green, 75 | 'Critical'.paint_yellow, 76 | 'Tests'.paint_white, 77 | 'Cases/Million'.paint_white 78 | ].freeze 79 | 80 | FULL_PROVINCE_TABLE_HEADINGS = [ 81 | 'Confirmed'.paint_white, 82 | 'Deaths'.paint_red, 83 | 'Recovered'.paint_green 84 | ].freeze 85 | 86 | FULL_STATE_TABLE_HEADINGS = [ 87 | 'Cases'.paint_white, 88 | 'Cases Today'.paint_white, 89 | 'Deaths'.paint_red, 90 | 'Deaths Today'.paint_red, 91 | 'Active'.paint_yellow, 92 | 'Tests'.paint_white 93 | ].freeze 94 | 95 | COMPARE_STATES_HEADINGS = [ 96 | 'State'.paint_white, 97 | 'Cases'.paint_white, 98 | 'Cases Today'.paint_white, 99 | 'Deaths'.paint_red, 100 | 'Deaths Today'.paint_red, 101 | 'Active'.paint_yellow 102 | ].freeze 103 | 104 | COMPARE_PROVINCES_HEADINGS = [ 105 | 'Province'.paint_white, 106 | 'Confirmed'.paint_white, 107 | 'Deaths'.paint_red, 108 | 'Recovered'.paint_green 109 | ].freeze 110 | 111 | FOOTER_LINE_COLUMN = [ 112 | '------------' 113 | ].freeze 114 | 115 | FOOTER_LINE_THREE_COLUMNS = FOOTER_LINE_COLUMN * 3 116 | 117 | FOOTER_LINE_FOUR_COLUMNS = FOOTER_LINE_COLUMN * 4 118 | 119 | # Add footer if rows exceed this number 120 | ADD_FOOTER_SIZE = 10 121 | 122 | COUNTRY_LETTERS = 'A'.upto('Z').with_index(127_462).to_h.freeze 123 | 124 | RIGHT_ALIGN_COLUMNS = { 125 | compare_country_table_full: [1, 2, 3, 4, 5, 6, 7], 126 | compare_country_table: [1, 2, 3, 4, 5], 127 | compare_us_states: [1, 2, 3, 4, 5], 128 | compare_provinces: [1, 2, 3] 129 | }.freeze 130 | 131 | USA_ABBREVIATIONS = { 132 | "al" => "Alabama", 133 | "ak" => "Alaska", 134 | "az" => "Arizona", 135 | "ar" => "Arkansas", 136 | "ca" => "California", 137 | "cz" => "Canal Zone", 138 | "co" => "Colorado", 139 | "ct" => "Connecticut", 140 | "de" => "Delaware", 141 | "dc" => "District of Columbia", 142 | "fl" => "Florida", 143 | "ga" => "Georgia", 144 | "gu" => "Guam", 145 | "hi" => "Hawaii", 146 | "id" => "Idaho", 147 | "il" => "Illinois", 148 | "in" => "Indiana", 149 | "ia" => "Iowa", 150 | "ks" => "Kansas", 151 | "ky" => "Kentucky", 152 | "la" => "Louisiana", 153 | "me" => "Maine", 154 | "md" => "Maryland", 155 | "ma" => "Massachusetts", 156 | "mi" => "Michigan", 157 | "mn" => "Minnesota", 158 | "ms" => "Mississippi", 159 | "mo" => "Missouri", 160 | "mt" => "Montana", 161 | "ne" => "Nebraska", 162 | "nv" => "Nevada", 163 | "nh" => "New Hampshire", 164 | "nj" => "New Jersey", 165 | "nm" => "New Mexico", 166 | "ny" => "New York", 167 | "nc" => "North Carolina", 168 | "nd" => "North Dakota", 169 | "oh" => "Ohio", 170 | "ok" => "Oklahoma", 171 | "or" => "Oregon", 172 | "pa" => "Pennsylvania", 173 | "pr" => "Puerto Rico", 174 | "ri" => "Rhode Island", 175 | "sc" => "South Carolina", 176 | "sd" => "South Dakota", 177 | "tn" => "Tennessee", 178 | "tx" => "Texas", 179 | "ut" => "Utah", 180 | "vt" => "Vermont", 181 | "vi" => "Virgin Islands", 182 | "va" => "Virginia", 183 | "wa" => "Washington", 184 | "wv" => "West Virginia", 185 | "wi" => "Wisconsin", 186 | "wy" => "Wyoming" 187 | } 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/kovid.svg)](https://badge.fury.io/rb/kovid) 2 | [![Open Source Helpers](https://www.codetriage.com/siaw23/kovid/badges/users.svg)](https://www.codetriage.com/siaw23/kovid) 3 | 4 | 5 | If you're looking to consume this in your Ruby-based application, you might want to check [Sarskov](https://github.com/siaw23/sarskov) out. Sarskov returns statistics in a JSON format. 6 | 7 | # 🦠 Kovid 8 | 9 | Kovid is a small CLI app to fetch data surrounding the coronavirus pandemic of 2019. I found myself checking [Wikipedia](https://en.wikipedia.org/wiki/2019%E2%80%9320_coronavirus_pandemic) constantly for information so I thought I'd build this to provide info directly in the terminal. It's where some of us spend time more. 10 | 11 | Code contribution and ideas welcome. 12 | 13 | 14 | ## ⚙️ Installation 15 | 16 | To install: 17 | 18 | * ️ Wash your hands with soap and water for at least 20 seconds. 19 | 20 | * Run `gem install kovid`. 21 | 22 | It's recommended you update often with `gem update kovid`. 23 | 24 | ## ⚒️ Usage 25 | 26 | You can run `kovid --help` to see the full list of available commands. 27 | 28 | #### Commands Overview 29 | 30 | 😷 **Fetching** 31 | * `kovid check COUNTRY` aliased as `kovid country COUNTRY`. 32 | * `kovid check COUNTRY -f` aliased as `kovid country COUNTRY --full`. 33 | 34 | You can get continental information with the following commands: 35 | 36 | * `kovid africa`. 37 | * `kovid europe`. 38 | * `kovid eu`. (The European Union) 39 | * `kovid sa`. (South America) 40 | * `kovid asia`. 41 | * `kovid world`. (Worldwide Statistics) 42 | 43 | 🇺🇸🇺🇸🇺🇸 44 | 45 | You can fetch US state-specific data: 46 | * `kovid state STATE` OR `kovid state "STATE NAME"`. 47 | * `kovid states --all` or `kovid states -a` for data on all US states. 48 | 49 | You can also use USPS abbreviations. Example: `kovid state me` 50 | 51 | Provinces 52 | 53 | You can fetch province specific data: 54 | 55 | * `kovid province PROVINCE` or `kovid province "PROVINCE NAME"`. 56 | 57 | ___ 58 | 😷 **Comparing** 59 | * `kovid compare FOO BAR` (sorts by cases DESC). 60 | * `kovid compare FOO BAR -f` OR `kovid compare FOO BAR --full` (sorts by cases DESC). 61 | 62 | Where `FOO` and `BAR` are different countries. 63 | 64 | You can compare as many countries as you want; `kovid compare FOO BAR BAZ` OR `kovid compare FOO BAR BAZ -f` 65 | 66 | 🇺🇸🇺🇸🇺🇸 67 | 68 | You can compare US states with: 69 | * `kovid states STATE STATE` Example: `kovid states illinois "new york" california` OR `kovid states il ny ca` 70 | 71 | You can compare provicnes with: 72 | * `kovid provinces PROVINCE PROVINCE` Example: `kovid provinces ontario manitoba` 73 | ___ 74 | 😷 **History** 75 | * `kovid history COUNTRY` (full history). 76 | * `kovid history COUNTRY N` (history in the last N days). 77 | * `kovid history STATE --usa` 78 | ___ 79 | 😷 **Top N (by cases/deaths for countries and US States)** 80 | * `kovid top N` (top N countries in number of cases). 81 | * `kovid top N -d` OR `kovid top N --deaths` (top N countries in number of deaths). 82 | * `kovid top N --states` (top N US states in number of cases). 83 | * `kovid top N --states -d` (top N countries in number of deaths). 84 | ___ 85 | 86 | **NOTE:** If you find it irritating to have to type `kovid state STATE`, `covid state STATE` works as well. 87 | 88 | 89 | #### Commands Details 90 | To fetch basic data on a country run: 91 | 92 | `kovid check ghana`. If the location contains spaces: `kovid check "Diamond Princess"` 93 | 94 | ![kovid](https://i.gyazo.com/1d86ba2cd05f215b16c8d1fd13085c6e.png "Covid data.") 95 | 96 | For full table info on a country: 97 | 98 | `kovid check italy -f` OR `kovid check italy --full` 99 | 100 | ![kovid](https://i.gyazo.com/1d9720b9fa2c08fb801f5361fba359bb.png "Covid data.") 101 | 102 | To compare country stats: 103 | 104 | `kovid compare germany poland spain` 105 | 106 | ![kovid](https://i.gyazo.com/4100e845fea6936f5c8d21d78617110d.png "Covid data.") 107 | 108 | To compare a countries stats with a full table: 109 | 110 | `kovid compare poland italy usa china -f` OR `kovid compare poland italy usa china --full` 111 | 112 | ![kovid](https://i.gyazo.com/8b57865ae9b28f5afa895ebc49a2de31.png "Covid data.") 113 | 114 | To fetch state-specific data run: 115 | 116 | `kovid state colorado` OR `kovid state "north carolina"` 117 | 118 | ![kovid](https://i.gyazo.com/51509c3986f56bbc25068e0d541d9bdd.png "Covid data.") 119 | 120 | To fetch EU data run: 121 | 122 | `kovid eu` 123 | 124 | ![kovid](https://i.gyazo.com/0a78afae2a5b9d2beb9f2c61dc1d3ac7.png "Covid data.") 125 | 126 | To fetch data on Africa: 127 | 128 | `kovid africa` 129 | 130 | ![kovid](https://i.gyazo.com/bc45fa53e2ff688e8a1f759f1bd1b972.png "Covid data.") 131 | 132 | You can check historical statistics by running 133 | 134 | `kovid history italy 7` eg: 135 | 136 | ![kovid](https://i.gyazo.com/bc18fdaf99cacf2c921086f189d542b4.png "Covid data.") 137 | 138 | To check for total figures: 139 | 140 | `kovid world` 141 | 142 | ![kovid](https://i.gyazo.com/e01f4769a2b9e31ce50cec212e55810c.png "Covid data.") 143 | 144 | To fetch top 5 countries in number of cases or deaths: 145 | 146 | `kovid top` 147 | 148 | ![kovid](https://i.gyazo.com/79443079a6c834094fc21c90dd02b78c.png "Covid data.") 149 | 150 | `kovid top --deaths` OR `kovid top -d` 151 | 152 | ![kovid](https://i.gyazo.com/8136a7acc2cb67d1621b3db0df822cd5.png "Covid data.") 153 | 154 | It is also possible to fetch top US states in number of cases or deaths: 155 | 156 | `kovid top --states` 157 | 158 | ![kovid](https://i.gyazo.com/7ee5a1e6affdec838783183024c4604d.png "Covid data.") 159 | 160 | `kovid top --states --deaths` OR `kovid top --states -d` 161 | 162 | ![kovid](https://i.gyazo.com/2c3cb7e1218deff44c9d440dab93a3b1.png "Covid data.") 163 | 164 | To fetch more number of countries or US states you can pass N. eg: 165 | 166 | `kovid top 10` 167 | 168 | ![kovid](https://i.gyazo.com/64663ff25c1ff61701e84871948640f4.png "Covid data.") 169 | 170 | 171 | ## Data Source 172 | > [JHU CSSE GISand Data](https://gisanddata.maps.arcgis.com/apps/opsdashboard/index.html#/bda7594740fd40299423467b48e9ecf6) and https://www.worldometers.info/coronavirus/ via [NovelCOVID/API](https://github.com/novelcovid/api) 173 | 174 | 175 | ## 👨‍💻 Development 176 | 177 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 178 | 179 | To install this gem onto your local machine, run `bundle exec rake install`. 180 | 181 | 182 | ## 🤲 Contributing 183 | 184 | There are multiple areas in this repo that can be improved or use some refactoring(there's a lot to be refactored in fact!). For that reason, bug reports and pull requests are welcome! This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/siaw23/kovid/blob/master/CODE_OF_CONDUCT.md). 185 | 186 | 187 | ## 🔖 License 188 | 189 | The gem is available as open-source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 190 | 191 | ## ❤️ Code of Conduct 192 | 193 | Everyone interacting in the Kovid project's codebases and issue trackers is expected to follow the [code of conduct](https://github.com/siaw23/kovid/blob/master/CODE_OF_CONDUCT.md). 194 | -------------------------------------------------------------------------------- /lib/kovid/ascii_charts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Copyright (c) 2011 Ben Lund 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | module Kovid 24 | module AsciiCharts 25 | VERSION = '0.9.1' 26 | 27 | class Chart 28 | attr_reader :options, :data 29 | 30 | DEFAULT_MAX_Y_VALS = 20 31 | DEFAULT_MIN_Y_VALS = 10 32 | 33 | # data is a sorted array of [x, y] pairs 34 | 35 | def initialize(data, options = {}) 36 | @data = data 37 | @options = options 38 | end 39 | 40 | def rounded_data 41 | @rounded_data ||= data.map { |pair| [pair[0], round_value(pair[1])] } 42 | end 43 | 44 | def step_size 45 | unless defined? @step_size 46 | if options[:y_step_size] 47 | @step_size = options[:y_step_size] 48 | else 49 | max_y_vals = options[:max_y_vals] || DEFAULT_MAX_Y_VALS 50 | min_y_vals = options[:max_y_vals] || DEFAULT_MIN_Y_VALS 51 | y_span = (max_yval - min_yval).to_f 52 | 53 | step_size = nearest_step(y_span.to_f / (data.size + 1)) 54 | 55 | if @all_ints && (step_size < 1) 56 | step_size = 1 57 | else 58 | while (y_span / step_size) < min_y_vals 59 | candidate_step_size = next_step_down(step_size) 60 | if @all_ints && (candidate_step_size < 1) 61 | break 62 | end ## don't go below one 63 | 64 | step_size = candidate_step_size 65 | end 66 | end 67 | 68 | # go up if we undershot, or were never over 69 | while (y_span / step_size) > max_y_vals 70 | step_size = next_step_up(step_size) 71 | end 72 | @step_size = step_size 73 | end 74 | if !@all_ints && @step_size.is_a?(Integer) 75 | @step_size = @step_size.to_f 76 | end 77 | end 78 | @step_size 79 | end 80 | 81 | STEPS = [1, 2, 5].freeze 82 | 83 | def from_step(val) 84 | if val == 0 85 | [0, 0] 86 | else 87 | order = Math.log10(val).floor.to_i 88 | num = (val / (10**order)) 89 | [num, order] 90 | end 91 | end 92 | 93 | def to_step(num, order) 94 | s = num * (10**order) 95 | if order < 0 96 | s.to_f 97 | else 98 | s 99 | end 100 | end 101 | 102 | def nearest_step(val) 103 | num, order = from_step(val) 104 | to_step(2, order) # #@@ 105 | end 106 | 107 | def next_step_up(val) 108 | num, order = from_step(val) 109 | next_index = STEPS.index(num.to_i) + 1 110 | if STEPS.size == next_index 111 | next_index = 0 112 | order += 1 113 | end 114 | to_step(STEPS[next_index], order) 115 | end 116 | 117 | def next_step_down(val) 118 | num, order = from_step(val) 119 | next_index = STEPS.index(num.to_i) - 1 120 | if next_index == -1 121 | STEPS.size - 1 122 | order -= 1 123 | end 124 | to_step(STEPS[next_index], order) 125 | end 126 | 127 | # round to nearest step size, making sure we curtail precision to same order of magnitude as the step size to avoid 0.4 + 0.2 = 0.6000000000000001 128 | def round_value(val) 129 | remainder = val % step_size 130 | unprecised = if (remainder * 2) >= step_size 131 | (val - remainder) + step_size 132 | else 133 | val - remainder 134 | end 135 | if step_size < 1 136 | precision = -Math.log10(step_size).floor 137 | (unprecised * (10**precision)).to_i.to_f / (10**precision) 138 | else 139 | unprecised 140 | end 141 | end 142 | 143 | def max_yval 144 | scan_data unless defined? @max_yval 145 | @max_yval 146 | end 147 | 148 | def min_yval 149 | scan_data unless defined? @min_yval 150 | @min_yval 151 | end 152 | 153 | def all_ints 154 | scan_data unless defined? @all_ints 155 | @all_ints 156 | end 157 | 158 | def scan_data 159 | @max_yval = 0 160 | @min_yval = 0 161 | @all_ints = true 162 | 163 | @max_xval_width = 1 164 | 165 | data.each do |pair| 166 | @max_yval = pair[1] if pair[1] > @max_yval 167 | @min_yval = pair[1] if pair[1] < @min_yval 168 | @all_ints = false if @all_ints && !pair[1].is_a?(Integer) 169 | 170 | if (xw = pair[0].to_s.length) > @max_xval_width 171 | @max_xval_width = xw 172 | end 173 | end 174 | end 175 | 176 | def max_xval_width 177 | scan_data unless defined? @max_xval_width 178 | @max_xval_width 179 | end 180 | 181 | def max_yval_width 182 | scan_y_range unless defined? @max_yval_width 183 | @max_yval_width 184 | end 185 | 186 | def scan_y_range 187 | @max_yval_width = 1 188 | 189 | y_range.each do |yval| 190 | if (yw = yval.to_s.length) > @max_yval_width 191 | @max_yval_width = yw 192 | end 193 | end 194 | end 195 | 196 | def y_range 197 | unless defined? @y_range 198 | @y_range = [] 199 | first_y = round_value(min_yval) 200 | first_y -= step_size if first_y > min_yval 201 | last_y = round_value(max_yval) 202 | last_y += step_size if last_y < max_yval 203 | current_y = first_y 204 | while current_y <= last_y 205 | @y_range << current_y 206 | current_y = round_value(current_y + step_size) ## to avoid fp arithmetic oddness 207 | end 208 | end 209 | @y_range 210 | end 211 | 212 | def lines 213 | raise 'lines must be overridden' 214 | end 215 | 216 | def draw 217 | lines.join("\n") 218 | end 219 | 220 | def to_string 221 | draw 222 | end 223 | end 224 | 225 | class Cartesian < Chart 226 | def lines 227 | return [[' ', options[:title], ' ', '|', '+-', ' ']] if data.empty? 228 | 229 | lines = [' '] 230 | 231 | bar_width = max_xval_width + 1 232 | 233 | lines << (' ' * max_yval_width) + ' ' + rounded_data.map { |pair| pair[0].to_s.center(bar_width) }.join('') 234 | 235 | y_range.each_with_index do |current_y, i| 236 | yval = current_y.to_s 237 | bar = if i == 0 238 | '+' 239 | else 240 | '|' 241 | end 242 | current_line = [(' ' * (max_yval_width - yval.length)) + "#{current_y}#{bar}"] 243 | 244 | rounded_data.each do |pair| 245 | marker = if (i == 0) && options[:hide_zero] 246 | '-' 247 | else 248 | '*' 249 | end 250 | filler = if i == 0 251 | '-' 252 | else 253 | ' ' 254 | end 255 | comparison = if options[:bar] 256 | current_y <= pair[1] 257 | else 258 | current_y == pair[1] 259 | end 260 | current_line << if comparison 261 | marker.center(bar_width, filler) 262 | else 263 | filler * bar_width 264 | end 265 | end 266 | lines << current_line.join('') 267 | current_y += step_size 268 | end 269 | lines << ' ' 270 | lines << options[:title].center(lines[1].length) if options[:title] 271 | lines << ' ' 272 | lines.reverse 273 | end 274 | end 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /spec/kovid_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Kovid do 4 | it 'has a version number' do 5 | expect(Kovid::VERSION).not_to be nil 6 | end 7 | 8 | describe 'province(name)' do 9 | let(:province) { 'ontario' } 10 | let(:inexistent_province) { 'wonderland' } 11 | it 'returns table with province data' do 12 | table = Kovid.province(province) 13 | 14 | expect(table.title).to include('ONTARIO') 15 | end 16 | 17 | it 'outputs message informing of wrong spelling or no reported case.' do 18 | table = Kovid.province(inexistent_province) 19 | 20 | expect(table.rows.first.cells.first.value).to eq( 21 | "Wrong spelling/No reported cases on #{inexistent_province.upcase}." 22 | ) 23 | end 24 | end 25 | 26 | describe 'provinces(names)' do 27 | let(:provinces) { %w[ontario manitoba] } 28 | 29 | it 'returns table with provinces data' do 30 | table = Kovid.provinces(provinces) 31 | 32 | first_columns = table.rows.map { |row| row.cells.first.value } 33 | 34 | expect(first_columns).to include('MANITOBA').and include('ONTARIO') 35 | end 36 | end 37 | 38 | describe 'country(name)' do 39 | let(:country) { 'ghana' } 40 | let(:inexistent_iso) { 'diamond princess' } 41 | let(:inexistent_country) { 'wonderland' } 42 | it 'returns table with country data' do 43 | table = Kovid.country(country) 44 | 45 | expect(table.title).to include('GHANA') 46 | end 47 | 48 | it 'returns table title with inexistent iso' do 49 | table = Kovid.country(inexistent_iso) 50 | 51 | expect(table.title).to eq('DIAMOND PRINCESS') 52 | end 53 | 54 | it 'outputs message informing of wrong spelling or no reported case.' do 55 | table = Kovid.country(inexistent_country) 56 | 57 | expect(table.rows.first.cells.first.value).to eq( 58 | "Wrong spelling/No reported cases on #{inexistent_country.upcase}." 59 | ) 60 | end 61 | end 62 | 63 | describe 'country_full(name)' do 64 | let(:country) { 'italy' } 65 | let(:inexistent_iso) { 'diamond princess' } 66 | let(:inexistent_country) { 'wonderland' } 67 | 68 | it 'returns table with country data' do 69 | table = Kovid.country_full(country) 70 | 71 | expect(table.title).to include('ITALY') 72 | end 73 | 74 | it 'returns table title with inexistent iso' do 75 | table = Kovid.country(inexistent_iso) 76 | 77 | expect(table.title).to eq('DIAMOND PRINCESS') 78 | end 79 | 80 | it 'outputs message informing of wrong spelling or no reported case.' do 81 | table = Kovid.country_full(inexistent_country) 82 | 83 | expect(table.rows.first.cells.first.value).to eq( 84 | "Wrong spelling/No reported cases on #{inexistent_country.upcase}." 85 | ) 86 | end 87 | end 88 | 89 | describe 'country_comparison(names_array)' do 90 | let(:country) { %w[ghana poland] } 91 | it 'returns table with country data' do 92 | table = Kovid.country_comparison(country) 93 | 94 | expect(table.headings.first.cells.last.value).to include('Tests') 95 | expect(table.headings.first.cells.first.value).to include('Country') 96 | end 97 | end 98 | 99 | describe 'country_comparison_full(names_array)' do 100 | let(:country) { %w[ghana poland] } 101 | it 'returns table with country data' do 102 | table = Kovid.country_comparison_full(country) 103 | 104 | expect(table.headings.first.cells.first.value).to include('Country') 105 | expect(table.headings.first.cells.last.value).to include('Cases/Million') 106 | end 107 | end 108 | 109 | describe 'cases' do 110 | it 'returns summary of cases' do 111 | table = Kovid.cases 112 | 113 | expect(table.headings.first.cells.first.value).to include('Cases') 114 | expect(table.headings.first.cells.last.value).to include('Recovered') 115 | end 116 | end 117 | 118 | describe 'eu_aggregate' do 119 | it 'returns collated data on the EU' do 120 | table = Kovid.eu_aggregate 121 | 122 | expect(table.headings.first.cells.first.value).to include('Cases') 123 | expect(table.headings.first.cells.last.value).to include('Critical') 124 | end 125 | end 126 | 127 | describe 'state' do 128 | it 'returns a US state data' do 129 | table = Kovid.state('michigan') 130 | 131 | expect(table.headings.first.cells.first.value).to include('Cases') 132 | expect(table.headings.first.cells.last.value).to include('Tests') 133 | end 134 | 135 | it 'accepts US state abbreviations' do 136 | expect(Kovid.state('michigan').title).to eq Kovid.state('mi').title 137 | expect(Kovid.state('Alabama').title).to eq Kovid.state('AL').title 138 | expect(Kovid.state('Maine').title).to eq Kovid.state('Me').title 139 | end 140 | 141 | it 'outputs message informing of wrong spelling or no reported case.' do 142 | expect(Kovid.state('Mediocristan').title).to eq('You checked: MEDIOCRISTAN') 143 | end 144 | end 145 | 146 | describe 'states' do 147 | let(:us_states) { %w[AK CA NY VA] } 148 | it 'returns table with state data' do 149 | table = Kovid.states(us_states) 150 | expect(table.rows.size).to eq(4) 151 | end 152 | end 153 | 154 | describe 'all_us_states' do 155 | before do 156 | allow(Kovid::Request).to receive(:all_us_states) 157 | end 158 | 159 | it 'calls all_us_states on Kovid::Request' do 160 | Kovid.all_us_states 161 | 162 | expect(Kovid::Request).to have_received(:all_us_states) 163 | end 164 | end 165 | 166 | describe 'history' do 167 | it 'returns history of given country' do 168 | table = Kovid.history('ghana', '7') 169 | expect(table.headings.first.cells.first.value).to include('Date') 170 | expect(table.headings.first.cells.last.value).to include('Recovered') 171 | end 172 | 173 | it 'outputs message informing no reported case.' do 174 | table = Kovid.history('Extremistan') 175 | 176 | expect(table.rows.first.cells.first.value).to start_with( 177 | 'Could not find cases for Extremistan' 178 | ) 179 | end 180 | 181 | it 'returns history of given state' do 182 | table = Kovid.history_us_state('va', '14') 183 | expect(table.headings.first.cells.first.value).to include('Date') 184 | expect(table.headings.first.cells.last.value).to include('Deaths') 185 | expect(table.title).to eq('VIRGINIA') 186 | end 187 | 188 | it 'returns correct amount of records' do 189 | table1 = Kovid.history_us_state('ny', '7') 190 | expect(table1.rows.size).to eq(7) 191 | 192 | table2 = Kovid.history_us_state('nj', 5) 193 | expect(table2.rows.size).to eq(5) 194 | end 195 | 196 | it 'adds footers rows when reocrds greater than 10' do 197 | table = Kovid.history_us_state('md', '22') 198 | expect(table.rows.size).to eq(24) 199 | end 200 | 201 | it 'defaults to days (30) for history' do 202 | table = Kovid.history_us_state('md') 203 | expect(table.title).to eq('MARYLAND') 204 | 205 | # Footer rows add two additional rows 206 | expect(table.rows.size).to eq(32) 207 | end 208 | 209 | it 'returns table title with non-existent country' do 210 | table = Kovid.history('DIAMOND PRINCESS') 211 | expect(table.title).to eq('DIAMOND PRINCESS') 212 | end 213 | end 214 | 215 | describe 'top' do 216 | it 'defaults to top countries in cases' do 217 | table = Kovid.top(5) 218 | expect(table.headings.first.cells.first.value).to include('Country') 219 | expect(table.headings.first.cells.last.value).to include('Cases/Million') 220 | expect(table.title).to include('TOP 5 COUNTRIES BY CASES') 221 | end 222 | 223 | it 'returns top countries in deaths' do 224 | table = Kovid.top(5, { location: :countries, incident: :deaths }) 225 | expect(table.headings.first.cells.first.value).to include('Country') 226 | expect(table.headings.first.cells.last.value).to include('Cases/Million') 227 | expect(table.title).to include('TOP 5 COUNTRIES BY DEATHS') 228 | end 229 | 230 | it 'returns top states in cases' do 231 | table = Kovid.top(5, { location: :states, incident: :cases }) 232 | expect(table.headings.first.cells.first.value).to include('State') 233 | expect(table.headings.first.cells.last.value).to include('Tests') 234 | expect(table.title).to include('TOP 5 STATES BY CASES') 235 | end 236 | 237 | it 'returns top states in deaths' do 238 | table = Kovid.top(5, { location: :states, incident: :deaths }) 239 | expect(table.headings.first.cells.first.value).to include('State') 240 | expect(table.headings.first.cells.last.value).to include('Tests') 241 | expect(table.title).to include('TOP 5 STATES BY DEATHS') 242 | end 243 | 244 | it 'returns correct amount of records' do 245 | table1 = Kovid.top(3) 246 | expect(table1.rows.size).to eq(3) 247 | 248 | table2 = Kovid.top(7) 249 | expect(table2.rows.size).to eq(7) 250 | end 251 | 252 | it 'returns sorted stat' do 253 | table = Kovid.top(5) 254 | cases = table.columns[1].sort.reverse 255 | expect(cases.sort.reverse).to eq(cases) 256 | end 257 | 258 | it 'adds footers rows when records greater than 10' do 259 | table = Kovid.top(17) 260 | expect(table.rows.size).to eq(19) 261 | end 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /lib/kovid/tablelize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'terminal-table' 4 | require 'date' 5 | require_relative 'ascii_charts' 6 | require_relative 'painter' 7 | require_relative 'constants' 8 | require_relative 'aggregators' 9 | require_relative 'historians' 10 | 11 | module Kovid 12 | class Tablelize 13 | extend Kovid::Constants 14 | extend Kovid::Aggregators 15 | extend Kovid::Historians 16 | 17 | class << self 18 | def country_table(data) 19 | Terminal::Table.new(title: country_title(data), 20 | headings: CASES_DEATHS_RECOVERED_CTODAY_DTODAY, 21 | rows: [country_row(data)]) 22 | end 23 | 24 | def full_country_table(data) 25 | Terminal::Table.new(title: country_title(data), 26 | headings: FULL_COUNTRY_TABLE_HEADINGS, 27 | rows: [full_country_row(data)]) 28 | end 29 | 30 | def full_province_table(province) 31 | Terminal::Table.new( 32 | title: province['province'].upcase, 33 | headings: FULL_PROVINCE_TABLE_HEADINGS, 34 | rows: [province_row(province)] 35 | ) 36 | end 37 | 38 | def full_state_table(state) 39 | Terminal::Table.new( 40 | title: state['state'].upcase, 41 | headings: FULL_STATE_TABLE_HEADINGS, 42 | rows: [state_row(state)] 43 | ) 44 | end 45 | 46 | def compare_countries_table(data) 47 | rows = [] 48 | 49 | data.each do |country| 50 | base_rows = country_row(country) 51 | rows << base_rows.unshift(country_title(country)) 52 | end 53 | 54 | align_columns(:compare_country_table, 55 | Terminal::Table.new( 56 | headings: COMPARE_COUNTRIES_TABLE_HEADINGS, 57 | rows: rows 58 | )) 59 | end 60 | 61 | def compare_countries_table_full(data) 62 | rows = data.map { |country| compare_countries_full_row(country) } 63 | 64 | align_columns(:compare_country_table_full, 65 | Terminal::Table.new(headings: COMPARE_COUNTRY_TABLE_FULL, 66 | rows: rows)) 67 | end 68 | 69 | def compare_us_states(data) 70 | rows = data.map.with_index do |state, index| 71 | if index.odd? 72 | us_state_row(state) 73 | else 74 | us_state_row(state).map(&:paint_highlight) 75 | end 76 | end 77 | 78 | align_columns(:compare_us_states, 79 | Terminal::Table.new(headings: COMPARE_STATES_HEADINGS, 80 | rows: rows)) 81 | end 82 | 83 | def compare_provinces(data) 84 | rows = data.map { |province| compare_provinces_row(province) } 85 | 86 | align_columns(:compare_provinces, 87 | Terminal::Table.new(headings: COMPARE_PROVINCES_HEADINGS, 88 | rows: rows)) 89 | end 90 | 91 | def cases(cases) 92 | Terminal::Table.new( 93 | title: '🌍 Total Number of Incidents Worldwide'.upcase, 94 | headings: CASES_DEATHS_RECOVERED, 95 | rows: [cases_row(cases)] 96 | ) 97 | end 98 | 99 | def top(data, options) 100 | headings = top_heading(options) 101 | rows = data.map { |location| top_row(location, options) } 102 | 103 | if options[:count] > 10 104 | rows << FOOTER_LINE_COLUMN * headings.count 105 | rows << headings 106 | end 107 | 108 | Terminal::Table.new( 109 | title: top_title(options), 110 | headings: headings, 111 | rows: rows 112 | ) 113 | end 114 | 115 | private 116 | 117 | def country_title(data) 118 | iso = data['countryInfo']['iso2'] 119 | if iso.nil? 120 | data['country'].upcase 121 | else 122 | "#{country_emoji(iso)} #{data['country'].upcase}" 123 | end 124 | end 125 | 126 | def country_emoji(iso) 127 | #TODO: FIx ZWSP 128 | COUNTRY_LETTERS.values_at(*iso.chars).pack('U*') + " " + \ 129 | 8203.chr(Encoding::UTF_8) 130 | end 131 | 132 | def cases_row(data) 133 | [ 134 | Kovid.comma_delimit(data['cases']), 135 | Kovid.comma_delimit(data['deaths']), 136 | Kovid.comma_delimit(data['recovered']) 137 | ] 138 | end 139 | 140 | def country_row(data) 141 | [ 142 | Kovid.comma_delimit(data['cases']), 143 | Kovid.add_plus_sign(data['todayCases']), 144 | Kovid.comma_delimit(data['deaths']), 145 | Kovid.add_plus_sign(data['todayDeaths']), 146 | Kovid.comma_delimit(data['active']), 147 | Kovid.comma_delimit(data['recovered']), 148 | Kovid.comma_delimit(data['tests']) 149 | ] 150 | end 151 | 152 | def full_country_row(data) 153 | [ 154 | Kovid.comma_delimit(data['cases']), 155 | Kovid.add_plus_sign(data['todayCases']), 156 | Kovid.comma_delimit(data['deaths']), 157 | Kovid.add_plus_sign(data['todayDeaths']), 158 | Kovid.comma_delimit(data['active']), 159 | Kovid.comma_delimit(data['recovered']), 160 | Kovid.comma_delimit(data['critical']), 161 | Kovid.comma_delimit(data['tests']), 162 | Kovid.comma_delimit(data['casesPerOneMillion']) 163 | ] 164 | end 165 | 166 | def state_row(data) 167 | [ 168 | Kovid.comma_delimit(data['cases']), 169 | Kovid.add_plus_sign(data['todayCases']), 170 | Kovid.comma_delimit(data['deaths']), 171 | Kovid.add_plus_sign(data['todayDeaths']), 172 | Kovid.comma_delimit(data['active']), 173 | Kovid.comma_delimit(data['tests']) 174 | ] 175 | end 176 | 177 | def province_row(data) 178 | [ 179 | data['stats']['confirmed'], 180 | data['stats']['deaths'], 181 | data['stats']['recovered'] 182 | ] 183 | end 184 | 185 | def compare_provinces_row(data) 186 | [ 187 | data['province'].upcase, 188 | province_row(data) 189 | ].flatten 190 | end 191 | 192 | def compare_countries_full_row(data) 193 | [ 194 | data.fetch('country'), 195 | full_country_row(data) 196 | ].flatten 197 | end 198 | 199 | def us_state_row(data) 200 | [ 201 | data.fetch('state').upcase, 202 | Kovid.comma_delimit(data.fetch('cases')), 203 | Kovid.add_plus_sign(data['todayCases']), 204 | Kovid.comma_delimit(data['deaths']), 205 | Kovid.add_plus_sign(data['todayDeaths']), 206 | Kovid.comma_delimit(data.fetch('active')) 207 | ] 208 | end 209 | 210 | def aggregated_row(data) 211 | [ 212 | Kovid.comma_delimit(data['cases']), 213 | Kovid.add_plus_sign(data['todayCases']), 214 | Kovid.comma_delimit(data['deaths']), 215 | Kovid.add_plus_sign(data['todayDeaths']), 216 | Kovid.comma_delimit(data['recovered']), 217 | Kovid.comma_delimit(data['active']), 218 | Kovid.comma_delimit(data['critical']) 219 | ] 220 | end 221 | 222 | def scale(msg) 223 | rows = [[msg]] 224 | puts Terminal::Table.new title: 'SCALE', rows: rows 225 | end 226 | 227 | def aggregated_table(collated_data, continent, iso, emoji) 228 | title = aggregated_table_title(continent, iso, emoji) 229 | 230 | Terminal::Table.new( 231 | title: title, 232 | headings: CONTINENTAL_AGGREGATE_HEADINGS, 233 | rows: [aggregated_row(collated_data)] 234 | ) 235 | end 236 | 237 | def aggregated_table_title(continent, iso, emoji) 238 | aggregated_data_continent = ' Aggregated Data on ' \ 239 | "#{continent} (#{iso.size} States)".upcase 240 | 241 | if emoji.codepoints.size > 1 242 | emoji + 8203.chr(Encoding::UTF_8) + aggregated_data_continent 243 | else 244 | emoji + aggregated_data_continent 245 | end 246 | end 247 | 248 | def align_columns(table_type, table) 249 | return table unless RIGHT_ALIGN_COLUMNS[table_type] 250 | 251 | RIGHT_ALIGN_COLUMNS[table_type].each do |col_no| 252 | table.align_column(col_no, :right) 253 | end 254 | table 255 | end 256 | 257 | def top_row(data, options) 258 | if options[:location] == :countries 259 | return [ 260 | country_title(data), 261 | full_country_row(data) 262 | ].flatten 263 | end 264 | 265 | [ 266 | data['state'].upcase, 267 | country_row(data) 268 | ].flatten 269 | end 270 | 271 | def top_heading(options) 272 | full_country_table = ['Country'.paint_white] + FULL_COUNTRY_TABLE_HEADINGS 273 | full_state_table = ['State'.paint_white] + FULL_STATE_TABLE_HEADINGS 274 | 275 | options[:location] == :countries ? full_country_table : full_state_table 276 | end 277 | 278 | def top_title(options) 279 | incident = options[:incident].to_s 280 | location = options[:location].to_s 281 | "🌍 Top #{options[:count]} #{location} by #{incident}".upcase 282 | end 283 | end 284 | end 285 | end 286 | -------------------------------------------------------------------------------- /lib/kovid/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'erb' 5 | require_relative 'tablelize' 6 | require_relative 'cache' 7 | require_relative 'uri_builder' 8 | 9 | module Kovid 10 | class Request 11 | COUNTRIES_PATH = UriBuilder.new('/v2/countries').url 12 | STATES_URL = UriBuilder.new('/v2/states').url 13 | JHUCSSE_URL = UriBuilder.new('/v2/jhucsse').url 14 | HISTORICAL_URL = UriBuilder.new('/v2/historical').url 15 | HISTORICAL_US_URL = UriBuilder.new('/v2/historical/usacounties').url 16 | 17 | SERVER_DOWN = 'Server overwhelmed. Please try again in a moment.' 18 | 19 | EU_ISOS = %w[AT BE BG CY CZ DE DK EE ES FI FR GR HR HU IE IT LT \ 20 | LU LV MT NL PL PT RO SE SI SK].freeze 21 | EUROPE_ISOS = EU_ISOS + %w[GB IS NO CH MC AD SM VA BA RS ME MK AL \ 22 | BY UA RU MD].freeze 23 | AFRICA_ISOS = %w[DZ AO BJ BW BF BI CM CV CF TD KM CD CG CI DJ EG \ 24 | GQ ER SZ ET GA GM GH GN GW KE LS LR LY MG MW ML \ 25 | MR MU MA MZ NA NE NG RW ST SN SC SL SO ZA SS SD \ 26 | TZ TG TN UG ZM ZW EH].freeze 27 | SOUTH_AMERICA_ISOS = %w[AR BO BV BR CL CO EC FK GF GY PY PE GS SR \ 28 | UY VE].freeze 29 | ASIA_ISOS = %w[AE AF AM AZ BD BH BN BT CC CN CX GE HK ID IL IN \ 30 | IQ IR JO JP KG KH KP KR KW KZ LA LB LK MM MN MO \ 31 | MY NP OM PH PK PS QA SA SG SY TH TJ TL TM TR TW \ 32 | UZ VN YE].freeze 33 | 34 | class << self 35 | def eu_aggregate 36 | eu_proc = proc do |data| 37 | Kovid::Tablelize.eu_aggregate(data) 38 | end 39 | 40 | aggregator(EU_ISOS, eu_proc) 41 | end 42 | 43 | def europe_aggregate 44 | europe_proc = proc do |data| 45 | Kovid::Tablelize.europe_aggregate(data) 46 | end 47 | 48 | aggregator(EUROPE_ISOS, europe_proc) 49 | end 50 | 51 | def africa_aggregate 52 | africa_proc = proc do |data| 53 | Kovid::Tablelize.africa_aggregate(data) 54 | end 55 | 56 | aggregator(AFRICA_ISOS, africa_proc) 57 | end 58 | 59 | def south_america_aggregate 60 | south_america_proc = proc do |data| 61 | Kovid::Tablelize.south_america_aggregate(data) 62 | end 63 | 64 | aggregator(SOUTH_AMERICA_ISOS, south_america_proc) 65 | end 66 | 67 | def asia_aggregate 68 | asia_proc = proc do |data| 69 | Kovid::Tablelize.asia_aggregate(data) 70 | end 71 | 72 | aggregator(ASIA_ISOS, asia_proc) 73 | end 74 | 75 | def by_country(country_name) 76 | response = fetch_country(country_name) 77 | 78 | if response.key?('message') 79 | not_found(country_name) 80 | else 81 | Kovid::Tablelize.country_table(response) 82 | end 83 | rescue JSON::ParserError 84 | puts SERVER_DOWN 85 | end 86 | 87 | def by_country_full(country_name) 88 | response = fetch_country(country_name) 89 | 90 | if response.key?('message') 91 | not_found(country_name) 92 | else 93 | Kovid::Tablelize.full_country_table(response) 94 | end 95 | rescue JSON::ParserError 96 | puts SERVER_DOWN 97 | end 98 | 99 | def province(province) 100 | response = fetch_province(province) 101 | if response.nil? 102 | not_found(province) 103 | else 104 | Kovid::Tablelize.full_province_table(response) 105 | end 106 | end 107 | 108 | def provinces(names) 109 | array = fetch_provinces(names) 110 | 111 | Kovid::Tablelize.compare_provinces(array) 112 | end 113 | 114 | def state(state) 115 | response = fetch_state(Kovid.lookup_us_state(state)) 116 | 117 | if response.nil? 118 | not_found(state) 119 | else 120 | Kovid::Tablelize.full_state_table(response) 121 | end 122 | rescue JSON::ParserError 123 | puts SERVER_DOWN 124 | end 125 | 126 | def states(states) 127 | compared_states = fetch_compared_states(states) 128 | Kovid::Tablelize.compare_us_states(compared_states) 129 | rescue JSON::ParserError 130 | puts SERVER_DOWN 131 | end 132 | 133 | def all_us_states 134 | state_data = fetch_state_data 135 | Kovid::Tablelize.compare_us_states(state_data) 136 | rescue JSON::ParserError 137 | puts SERVER_DOWN 138 | end 139 | 140 | def by_country_comparison(list) 141 | array = fetch_countries(list) 142 | Kovid::Tablelize.compare_countries_table(array) 143 | rescue JSON::ParserError 144 | puts SERVER_DOWN 145 | end 146 | 147 | def by_country_comparison_full(list) 148 | array = fetch_countries(list) 149 | Kovid::Tablelize.compare_countries_table_full(array) 150 | rescue JSON::ParserError 151 | puts SERVER_DOWN 152 | end 153 | 154 | def cases 155 | response = JSON.parse( 156 | Typhoeus.get(UriBuilder.new('/v2/all').url, cache_ttl: 900).response_body 157 | ) 158 | 159 | Kovid::Tablelize.cases(response) 160 | rescue JSON::ParserError 161 | puts SERVER_DOWN 162 | end 163 | 164 | def history(country, days) 165 | response = fetch_history(country) 166 | 167 | if response.key?('message') 168 | not_found(country) do |c| 169 | "Could not find cases for #{c}.\nIf searching United States, add --usa option" 170 | end 171 | else 172 | Kovid::Tablelize.history(response, days) 173 | end 174 | rescue JSON::ParserError 175 | puts SERVER_DOWN 176 | end 177 | 178 | def history_us_state(state, days) 179 | state = Kovid.lookup_us_state(state).downcase 180 | response = fetch_us_history(state) 181 | 182 | if response.respond_to?(:key?) && response.key?('message') 183 | return not_found(state) 184 | end 185 | 186 | # API Endpoint returns list of counties for given state, so 187 | # we aggreage cases for all counties 188 | # Note: no data for 'Recovered' 189 | cases = usacounties_aggregator(response, 'cases') 190 | deaths = usacounties_aggregator(response, 'deaths') 191 | 192 | response = { 193 | 'state' => state, 194 | 'timeline' => { 'cases' => cases, 'deaths' => deaths } 195 | } 196 | 197 | Kovid::Tablelize.history_us_state(response, days) 198 | rescue JSON::ParserError 199 | puts SERVER_DOWN 200 | end 201 | 202 | def histogram(country, date) 203 | response = JSON.parse( 204 | Typhoeus.get( 205 | HISTORICAL_URL + "/#{country}", cache_ttl: 900 206 | ).response_body 207 | ) 208 | 209 | Kovid::Tablelize.histogram(response, date) 210 | end 211 | 212 | def top(count, options) 213 | response = JSON.parse( 214 | Typhoeus.get( 215 | top_url(options[:location]) + 216 | "?sort=#{options[:incident]}", 217 | cache_ttl: 900 218 | ).response_body 219 | ) 220 | 221 | Kovid::Tablelize.top(response.first(count), 222 | options.merge({ count: count })) 223 | end 224 | 225 | def capitalize_words(string) 226 | string.split.map(&:capitalize).join(' ') 227 | end 228 | 229 | private 230 | 231 | def not_found(location) 232 | rows = [] 233 | default_warning = "Wrong spelling/No reported cases on #{location.upcase}." 234 | 235 | rows << if block_given? 236 | [yield(location)] 237 | else 238 | [default_warning] 239 | end 240 | 241 | Terminal::Table.new title: "You checked: #{location.upcase}", rows: rows 242 | end 243 | 244 | def fetch_countries(list) 245 | list.map do |country| 246 | JSON.parse( 247 | Typhoeus.get( 248 | COUNTRIES_PATH + "/#{country}", cache_ttl: 900 249 | ).response_body 250 | ) 251 | end.sort_by { |json| -json['cases'] } 252 | end 253 | 254 | def fetch_compared_states(submitted_states) 255 | submitted_states.map! { |s| Kovid.lookup_us_state(s) } 256 | 257 | fetch_state_data.select do |state| 258 | submitted_states.include?(state['state']) 259 | end 260 | end 261 | 262 | def fetch_state_data 263 | JSON.parse(Typhoeus.get(STATES_URL, cache_ttl: 900).response_body) 264 | end 265 | 266 | def fetch_country(country_name) 267 | # TODO: Match ISOs to full country names 268 | country_name = 'nl' if country_name == 'netherlands' 269 | country_url = COUNTRIES_PATH + "/#{ERB::Util.url_encode(country_name)}" 270 | 271 | JSON.parse(Typhoeus.get(country_url, cache_ttl: 900).response_body) 272 | end 273 | 274 | def fetch_jhucsse 275 | JSON.parse(Typhoeus.get(JHUCSSE_URL, cache_ttl: 900).response_body) 276 | end 277 | 278 | def fetch_province(province) 279 | response = fetch_jhucsse 280 | response.select do |datum| 281 | datum['province'] == capitalize_words(province) 282 | end.first 283 | end 284 | 285 | def fetch_provinces(provinces) 286 | provinces.map!(&method(:capitalize_words)) 287 | response = fetch_jhucsse 288 | response.select { |datum| provinces.include? datum['province'] } 289 | end 290 | 291 | def fetch_state(state) 292 | states_array = JSON.parse( 293 | Typhoeus.get(STATES_URL, cache_ttl: 900).response_body 294 | ) 295 | 296 | states_array.select do |state_name| 297 | state_name['state'] == capitalize_words(state) 298 | end.first 299 | end 300 | 301 | def fetch_history(country) 302 | JSON.parse( 303 | Typhoeus.get( 304 | HISTORICAL_URL + "/#{ERB::Util.url_encode(country)}", cache_ttl: 900 305 | ).response_body 306 | ) 307 | end 308 | 309 | def fetch_us_history(state) 310 | JSON.parse( 311 | Typhoeus.get( 312 | HISTORICAL_US_URL + "/#{ERB::Util.url_encode(state)}", cache_ttl: 900 313 | ).response_body 314 | ) 315 | end 316 | 317 | def aggregator(isos, meth) 318 | countries_array = JSON.parse(countries_request) 319 | country_array = countries_array.select do |hash| 320 | isos.include?(hash['countryInfo']['iso2']) 321 | end 322 | data = countries_aggregator(country_array) 323 | 324 | meth === data 325 | rescue JSON::ParserError 326 | puts SERVER_DOWN 327 | end 328 | 329 | def countries_request 330 | Typhoeus.get(COUNTRIES_PATH, cache_ttl: 900).response_body 331 | end 332 | 333 | def countries_aggregator(country_array) 334 | country_array.inject do |base, other| 335 | base.merge(other) do |key, left, right| 336 | left ||= 0 337 | right ||= 0 338 | 339 | left + right unless %w[country countryInfo].include?(key) 340 | end 341 | end.compact 342 | end 343 | 344 | def usacounties_aggregator(data, key = nil) 345 | data.inject({}) do |base, other| 346 | base.merge(other['timeline'][key]) do |_k, l, r| 347 | l ||= 0 348 | r ||= 0 349 | l + r 350 | end 351 | end.compact 352 | end 353 | 354 | def top_url(location) 355 | location == :countries ? COUNTRIES_PATH : STATES_URL 356 | end 357 | end 358 | end 359 | end 360 | --------------------------------------------------------------------------------