diff --git a/Gemfile.lock b/Gemfile.lock index b392990b5b..c553b6b67c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -446,6 +446,9 @@ GEM unf_ext (0.0.8.2) unicode-display_width (2.4.2) uniform_notifier (1.16.0) + unparser (0.6.7) + diff-lcs (~> 1.3) + parser (>= 3.2.0) view_component (3.0.0) activesupport (>= 5.2.0, < 8.0) concurrent-ruby (~> 1.0) diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 1d2be30eba..e7fc2a5e89 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -1,5 +1,6 @@ class HealthController < ApplicationController skip_before_action :authenticate_user! + skip_after_action :verify_authorized def index respond_to do |format| @@ -10,4 +11,15 @@ def index format.json { render json: {latest_deploy_time: Health.instance.latest_deploy_time} } end end + + def case_contacts_creation_times_in_last_week + # Get the case contacts created in the last week + case_contacts = CaseContact.where("created_at >= ?", 10.week.ago) + + # Extract the created_at timestamps and convert them to Unix time + timestamps = case_contacts.pluck(:created_at).map { |t| t.to_i } + + # Return the timestamps as a JSON response + render json: {timestamps: timestamps} + end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 178513b4c7..db83def93f 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -33,3 +33,4 @@ require('./src/select') require('./src/sidebar') require('./src/tooltip') require('./src/session_timeout_poller.js') +require('./src/display_app_metric.js') diff --git a/app/javascript/src/display_app_metric.js b/app/javascript/src/display_app_metric.js new file mode 100644 index 0000000000..a944c14de0 --- /dev/null +++ b/app/javascript/src/display_app_metric.js @@ -0,0 +1,120 @@ +import { Chart, registerables } from 'chart.js' +import 'chartjs-adapter-luxon' + +Chart.register(...registerables) + +const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + +$(document).ready(function () { + $.ajax({ + type: 'GET', + url: '/health/case_contacts_creation_times_in_last_week', + success: function (data) { + const timestamps = data.timestamps + + const counts = getCountsByDayAndHour(timestamps) + const dataset = getDatasetFromCounts(counts) + + createChart(dataset) + } + }) +}) + +function getCountsByDayAndHour (timestamps) { + const counts = {} + + for (let i = 0; i < timestamps.length; i++) { + const timestamp = new Date(timestamps[i] * 1000) + const day = days[timestamp.getUTCDay()] + const hour = timestamp.getUTCHours() + const key = day + ' ' + hour + counts[key] = (counts[key] || 0) + 1 + } + + return counts +} + +function getDatasetFromCounts (counts) { + const dataset = [] + + for (const key in counts) { + const parts = key.split(' ') + const day = parts[0] + const hour = parseInt(parts[1]) + const count = counts[key] + + dataset.push({ + x: hour, + y: days.indexOf(day), + r: Math.sqrt(count) * 2, + count + }) + } + + return dataset +} + +function createChart (dataset) { + const ctx = document.getElementById('myChart').getContext('2d') + // eslint-disable-next-line no-unused-vars + const myChart = new Chart(ctx, { + type: 'bubble', + data: { + datasets: [{ + label: 'Case Contacts Creation Times in Last Week', + data: dataset, + backgroundColor: 'rgba(255, 99, 132, 0.2)', + borderColor: 'rgba(255, 99, 132, 1)' + }] + }, + options: getChartOptions() + }) +} + +function getChartOptions () { + return { + scales: { + x: getXScale(), + y: getYScale() + }, + plugins: { + tooltip: { + callbacks: { + label: getTooltipLabelCallback() + } + } + } + } +} + +function getXScale () { + return { + ticks: { + beginAtZero: true, + stepSize: 1 + } + } +} + +function getYScale () { + return { + ticks: { + beginAtZero: true, + stepSize: 1, + callback: getYTickCallback() + } + } +} + +function getYTickCallback () { + return function (value, index, values) { + return days[value] + } +} + +function getTooltipLabelCallback () { + return function (context) { + const datum = context.dataset.data[context.dataIndex] + return datum.count + ' case contacts created on ' + days[datum.y] + ' at ' + datum.x + ':00' + } +} diff --git a/app/views/health/index.html.erb b/app/views/health/index.html.erb index 8ce787f915..c7f7a54cad 100644 --- a/app/views/health/index.html.erb +++ b/app/views/health/index.html.erb @@ -1,2 +1,4 @@
+ +

Chart: Display App Metric

diff --git a/config/routes.rb b/config/routes.rb index b8ac514430..0a7d68cea8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,7 +24,11 @@ root to: "all_casa_admins/sessions#new", as: :unauthenticated_all_casa_root end - resources :health, only: %i[index] + resources :health, only: %i[index] do + collection do + get :case_contacts_creation_times_in_last_week + end + end get "/.well-known/assetlinks.json", to: "android_app_associations#index" resources :casa_cases, except: %i[destroy] do diff --git a/package.json b/package.json index 993cdf9c49..bd0722694d 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,14 @@ "bootstrap-datepicker": "^1.10.0", "bootstrap-scss": "^5.2.3", "bootstrap-select": "^1.13.18", + "chart.js": "^4.2.1", + "chartjs-adapter-luxon": "^1.3.1", "datatables.net-dt": "^1.13.4", "esbuild": "^0.17.19", "faker": "^5.5.3", "jquery": "^3.6.4", "lodash": "^4.17.21", + "luxon": "^3.3.0", "popper.js": "^1.16.1", "sass": "^1.62.1", "select2": "^4.0.13", diff --git a/spec/requests/health_spec.rb b/spec/requests/health_spec.rb index 1d56050271..9d18d1860f 100644 --- a/spec/requests/health_spec.rb +++ b/spec/requests/health_spec.rb @@ -32,4 +32,17 @@ expect(hash_body.keys).to match_array(["latest_deploy_time"]) end end + + describe "GET #case_contacts_creation_times_in_last_week" do + it "returns timestamps of case contacts created in the last week" do + case_contact1 = create(:case_contact, created_at: 1.week.ago) + case_contact2 = create(:case_contact, created_at: 2.weeks.ago) + get case_contacts_creation_times_in_last_week_health_index_path + expect(response).to have_http_status(:ok) + expect(response.content_type).to include("application/json") + timestamps = JSON.parse(response.body)["timestamps"] + expect(timestamps).to include(case_contact1.created_at.to_i) + expect(timestamps).not_to include(case_contact2.created_at.iso8601(3)) + end + end end diff --git a/spec/system/imports/index_spec.rb b/spec/system/imports/index_spec.rb index b340d219d3..671cd97321 100644 --- a/spec/system/imports/index_spec.rb +++ b/spec/system/imports/index_spec.rb @@ -1,13 +1,11 @@ require "rails_helper" RSpec.describe "imports/index", type: :system do - let(:volunteer) { create(:volunteer) } - let(:admin) { create(:casa_admin) } - context "as a volunteer" do - before { sign_in volunteer } - it "redirects the user with an error message" do + volunteer = create(:volunteer) + + sign_in volunteer visit imports_path expect(page).to have_selector(".alert", text: "Sorry, you are not authorized to perform this action.") @@ -15,9 +13,10 @@ end context "import volunteer csv with phone numbers", js: true do - let(:import_file_path) { Rails.root.join("spec", "fixtures", "volunteers.csv") } - it "shows sms opt in modal" do + import_file_path = Rails.root.join("spec", "fixtures", "volunteers.csv") + admin = create(:casa_admin) + sign_in admin visit imports_path(:volunteer) @@ -38,9 +37,10 @@ end context "import volunteer csv without phone numbers", js: true do - let(:import_file_path) { Rails.root.join("spec", "fixtures", "volunteers_without_phone_numbers.csv") } - it "shows successful import" do + import_file_path = Rails.root.join("spec", "fixtures", "volunteers_without_phone_numbers.csv") + admin = create(:casa_admin) + sign_in admin visit imports_path(:volunteer) @@ -54,9 +54,10 @@ end context "import supervisors csv with phone numbers", js: true do - let(:import_file_path) { Rails.root.join("spec", "fixtures", "supervisors.csv") } - it "shows sms opt in modal" do + import_file_path = Rails.root.join("spec", "fixtures", "supervisors.csv") + admin = create(:casa_admin) + sign_in admin visit imports_path click_on "supervisor-tab" @@ -78,12 +79,14 @@ end context "import supervisors csv without phone numbers", js: true do - let(:import_file_path) { Rails.root.join("spec", "fixtures", "supervisors_without_phone_numbers.csv") } - it "shows successful import" do + import_file_path = Rails.root.join("spec", "fixtures", "supervisors_without_phone_numbers.csv") + admin = create(:casa_admin) + sign_in admin visit imports_path - click_link "supervisor-tab" + + click_on "Import Supervisors" expect(page).to have_content("Import Supervisors") diff --git a/yarn.lock b/yarn.lock index 8d994e4e73..6c9c606d9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1670,6 +1670,11 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@kurkle/color@^0.3.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.2.tgz#5acd38242e8bde4f9986e7913c8fdf49d3aa199f" + integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -2288,6 +2293,18 @@ char-regex@^1.0.2: resolved "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +chart.js@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.2.1.tgz#d2bd5c98e9a0ae35408975b638f40513b067ba1d" + integrity sha512-6YbpQ0nt3NovAgOzbkSSeeAQu/3za1319dPUQTXn9WcOpywM8rGKxJHrhS8V8xEkAlk8YhEfjbuAPfUyp6jIsw== + dependencies: + "@kurkle/color" "^0.3.0" + +chartjs-adapter-luxon@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.3.1.tgz#8ad8be7cc1521bacd6cd2ff4b013669d4dd49364" + integrity sha512-yxHov3X8y+reIibl1o+j18xzrcdddCLqsXhriV2+aQ4hCR66IYFchlRXUvrJVoxglJ380pgytU7YWtoqdIgqhg== + check-more-types@2.24.0: version "2.24.0" resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz" @@ -4283,6 +4300,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +luxon@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.3.0.tgz#d73ab5b5d2b49a461c47cedbc7e73309b4805b48" + integrity sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg== + make-dir@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz"