Move point creation to a background job, add months navigation to the sidebar

This commit is contained in:
Eugene Burmakin 2024-04-02 17:37:38 +02:00
parent b6769676c3
commit 5544bcd5ff
22 changed files with 168 additions and 56 deletions

View file

@ -1 +1,4 @@
require: rubocop-rails
Style/Documentation:
Enabled: false

View file

@ -15,10 +15,11 @@ gem 'stimulus-rails'
gem 'tailwindcss-rails'
gem 'turbo-rails'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
gem "importmap-rails"
gem "chartkick"
gem 'importmap-rails'
gem 'chartkick'
gem 'geocoder'
gem 'sidekiq'
gem 'sidekiq-cron'
group :development, :test do
@ -42,4 +43,4 @@ group :development do
end
# Use Redis for Action Cable
gem "redis"
gem 'redis'

View file

@ -106,6 +106,8 @@ GEM
railties (>= 6.1)
drb (2.2.1)
erubi (1.12.0)
et-orbi (1.2.11)
tzinfo
factory_bot (6.4.6)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.3)
@ -113,6 +115,9 @@ GEM
railties (>= 5.0.0)
ffaker (2.23.0)
foreman (0.87.2)
fugit (1.10.1)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
geocoder (1.8.2)
globalid (1.2.1)
activesupport (>= 6.1)
@ -178,6 +183,7 @@ GEM
nio4r (~> 2.0)
pundit (2.3.1)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.3)
rack (3.0.10)
rack-session (2.0.0)
@ -274,6 +280,10 @@ GEM
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.19.0)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
@ -343,6 +353,7 @@ DEPENDENCIES
rubocop-rails
shoulda-matchers
sidekiq
sidekiq-cron
simplecov
sprockets-rails
stimulus-rails

View file

@ -1,3 +1,3 @@
web: bin/rails server -p 3000 -b 0.0.0.0
css: bin/rails tailwindcss:watch
worker: bundle exec sidekiq
worker: bundle exec sidekiq -C config/sidekiq.yml

View file

@ -2,15 +2,9 @@ class Api::V1::PointsController < ApplicationController
skip_forgery_protection
def create
parsed_params = OwnTracks::Params.new(point_params).call
PointCreatingJob.perform_later(point_params)
@point = Point.create(parsed_params)
if @point.valid?
render json: @point, status: :ok
else
render json: @point.errors, status: :unprocessable_entity
end
render json: {}, status: :ok
end
def destroy

View file

@ -11,6 +11,7 @@ class PointsController < ApplicationController
@distance = distance
@start_at = Time.zone.at(start_at)
@end_at = Time.zone.at(end_at)
@years = (@start_at.year..@end_at.year).to_a
end
private
@ -18,13 +19,13 @@ class PointsController < ApplicationController
def start_at
return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil?
params[:start_at].to_datetime.to_i
Time.parse(params[:start_at]).to_i
end
def end_at
return Time.zone.today.end_of_day.to_i if params[:end_at].nil?
params[:end_at].to_datetime.to_i
Time.parse(params[:end_at]).to_i
end
def distance

View file

@ -17,10 +17,18 @@ module ApplicationHelper
end
def year_timespan(year)
start_at = DateTime.new(year).beginning_of_year.to_time.strftime('%Y-%m-%dT%H:%M')
end_at = DateTime.new(year).end_of_year.to_time.strftime('%Y-%m-%dT%H:%M')
start_at = Time.utc(year).in_time_zone('Europe/Berlin').beginning_of_year.strftime('%Y-%m-%dT%H:%M')
end_at = Time.utc(year).in_time_zone('Europe/Berlin').end_of_year.strftime('%Y-%m-%dT%H:%M')
{ start_at: start_at, end_at: end_at }
{ start_at:, end_at: }
end
def timespan(month, year)
month = DateTime.new(year, month).in_time_zone(Time.zone)
start_at = month.beginning_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
end_at = month.end_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
{ start_at:, end_at: }
end
def header_colors
@ -38,4 +46,14 @@ module ApplicationHelper
def year_distance_stat_in_km(year)
Stat.year_distance(year).sum { _1[1] }
end
def is_past?(year, month)
DateTime.new(year, month).past?
end
def points_exist?(year, month)
Point.where(
timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
).exists?
end
end

View file

@ -0,0 +1,9 @@
class PointCreatingJob < ApplicationJob
queue_as :default
def perform(point_params)
parsed_params = OwnTracks::Params.new(point_params).call
point = Point.create(parsed_params)
end
end

View file

@ -1,7 +1,9 @@
# frozen_string_literal: true
class StatCreatingJob < ApplicationJob
queue_as :default
def perform(user_id)
CreateStats.new(user_id).call
def perform(user_ids = nil)
CreateStats.new(user_ids).call
end
end

View file

@ -51,4 +51,10 @@ class Stat < ApplicationRecord
{ countries: data.count, cities: data.sum { |country| country[:cities].count } }
end
def self.years
starting_year = pluck(:year).uniq.min || Time.current.year
(starting_year..Time.current.year).to_a.reverse
end
end

View file

@ -37,8 +37,10 @@ class CountriesAndCities
end
end
def filter_cities(mapped_with_cities)
# In future, we would want to remove cities where user spent less than
# 1 hour per day
# Remove cities with less than MINIMUM_POINTS_IN_CITY
mapped_with_cities.transform_values do |cities|
cities.reject { |_, data| data[:points] < MINIMUM_POINTS_IN_CITY }
@ -48,8 +50,8 @@ class CountriesAndCities
def normalize_result(hash)
hash.map do |country, cities|
{
country: country,
cities: cities.map { |city, data| { city: city, points: data[:points], timestamp: data[:timestamp] } }
country:,
cities: cities.map { |city, data| { city:, points: data[:points], timestamp: data[:timestamp] } }
}
end
end

View file

@ -1,17 +1,18 @@
# frozen_string_literal: true
class CreateStats
attr_reader :years, :months, :user
attr_reader :years, :months, :users
def initialize(user_id)
@user = User.find(user_id)
def initialize(user_ids)
@users = User.where(id: user_ids)
@years = (1970..Time.current.year).to_a
@months = (1..12).to_a
end
def call
years.flat_map do |year|
months.map do |month|
users.each do |user|
years.each do |year|
months.each do |month|
beginning_of_month_timestamp = DateTime.new(year, month).beginning_of_month.to_i
end_of_month_timestamp = DateTime.new(year, month).end_of_month.to_i
@ -26,7 +27,8 @@ class CreateStats
stat
end
end.compact
end
end
end
private

View file

@ -6,7 +6,7 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.7.2/dist/full.css" rel="stylesheet" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.9.0/dist/full.css" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>

View file

@ -1,4 +1,4 @@
<div class='w-3/4 mt-10'>
<div class='w-4/5 mt-10'>
<div class="flex flex-col space-y-4 mb-4 w-full">
<%= form_with url: points_path, method: :get do |f| %>
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
@ -32,7 +32,7 @@
</div>
</div>
<div class='w-1/4 mt-10'>
<div class='w-1/5 mt-10'>
<%= render 'shared/right_sidebar' %>
</div>

View file

@ -1,6 +1,36 @@
<%= "#{@distance} km" if @distance %>
<div id='years-nav'>
<div class="dropdown">
<div tabindex="0" role="button" class="btn m-1">Select year</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<% Stat.years.each do |year| %>
<li><%= link_to year, points_url(year_timespan(year).merge(year: year)) %></li>
<% end %>
</ul>
</div>
<% @years.each do |year| %>
<h3 class='text-xl'>
<%= year %>
</h3>
<div class='grid grid-cols-3 gap-3'>
<% (1..12).to_a.each_slice(3) do |months| %>
<% months.each do |month_number| %>
<% if is_past?(year, month_number) && points_exist?(year, month_number) %>
<%= link_to Date::ABBR_MONTHNAMES[month_number], points_url(timespan(month_number, year)), class: 'btn btn-default' %>
<% else %>
<div class='btn btn-disabled'><%= Date::ABBR_MONTHNAMES[month_number] %></div>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% if REVERSE_GEOCODING_ENABLED && @countries_and_cities&.any? %>
<hr class='my-5'>
<% @countries_and_cities.each do |country| %>
<h2 class="text-lg font-semibold mt-5">
<%= country[:country] %> (<%= country[:cities].count %> cities)

View file

@ -48,10 +48,16 @@
<%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %>
<%= link_to '[Map]', points_url(year_timespan(year)), class: 'underline hover:no-underline' %>
</h2>
<p><%= number_with_delimiter year_distance_stat_in_km(year) %>km</p>
<p>
<% cache [current_user, 'year_distance_stat_in_km', year], skip_digest: true do %>
<%= number_with_delimiter year_distance_stat_in_km(year) %>km
<% end %>
</p>
<% if REVERSE_GEOCODING_ENABLED %>
<div class="card-actions justify-end">
<% cache [current_user, 'countries_and_cities_stat', year], skip_digest: true do %>
<%= countries_and_cities_stat(year) %>
<% end %>
</div>
<% end %>
<%= column_chart(

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
MINIMUM_POINTS_IN_CITY = ENV.fetch('MINIMUM_POINTS_IN_CITY', 5).to_i
MAP_CENTER = ENV.fetch('MAP_CENTER', '[55.7522, 37.6156]')
REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true'

6
config/schedule.yml Normal file
View file

@ -0,0 +1,6 @@
# config/schedule.yml
stat_creating_job:
cron: "0 */6 * * *"
class: "StatCreatingJob"
queue: default

View file

@ -1,5 +1,16 @@
require 'rails_helper'
RSpec.describe ImportJob, type: :job do
pending "add some examples to (or delete) #{__FILE__}"
describe '#perform' do
subject(:perform) { described_class.new.perform(user.id, import.id) }
let(:file_path) { 'spec/fixtures/owntracks_export.json' }
let(:file) { fixture_file_upload(file_path) }
let(:user) { create(:user) }
let(:import) { create(:import, user: user, file: file, name: File.basename(file.path)) }
it 'creates points' do
expect { perform }.to change { Point.count }.by(8)
end
end
end

View file

@ -0,0 +1,15 @@
require 'rails_helper'
RSpec.describe PointCreatingJob, type: :job do
describe '#perform' do
subject(:perform) { described_class.new.perform(point_params) }
let(:point_params) do
{ lat: 1.0, lon: 1.0, tid: 'test', tst: Time.now.to_i, topic: 'iPhone 12 pro' }
end
it 'creates a point' do
expect { perform }.to change { Point.count }.by(1)
end
end
end

View file

@ -12,18 +12,11 @@ RSpec.describe "Api::V1::Points", type: :request do
expect(response).to have_http_status(:success)
end
end
context 'with invalid params' do
let(:params) do
{ lat: 1.0, lon: 1.0, tid: 'test', tst: Time.now.to_i }
end
it "returns http unprocessable_entity" do
it 'enqueues a job' do
expect {
post api_v1_points_path, params: params
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to eq("{\"topic\":[\"can't be blank\"]}")
}.to have_enqueued_job(PointCreatingJob)
end
end
end

View file

@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe OwnTracks::ExportParser do
describe '#call' do
subject(:parser) { described_class.new(import.id).call }
subject(:parser) { described_class.new(import).call }
let(:file_path) { 'spec/fixtures/owntracks_export.json' }
let(:file) { fixture_file_upload(file_path) }