diff --git a/.rubocop.yml b/.rubocop.yml index ea725c1b..aaa6befd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1 +1,4 @@ require: rubocop-rails + +Style/Documentation: + Enabled: false diff --git a/Gemfile b/Gemfile index 92b6a822..cb1e7c09 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 3a497dd8..a1d19b80 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/Procfile.dev b/Procfile.dev index adb58e23..67106a1e 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -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 diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index aa1e280b..98aeea70 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -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 diff --git a/app/controllers/points_controller.rb b/app/controllers/points_controller.rb index 9c57fac5..ba1c3fc8 100644 --- a/app/controllers/points_controller.rb +++ b/app/controllers/points_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f7a13f81..f871dd6a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/jobs/point_creating_job.rb b/app/jobs/point_creating_job.rb new file mode 100644 index 00000000..c7899d04 --- /dev/null +++ b/app/jobs/point_creating_job.rb @@ -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 diff --git a/app/jobs/stat_creating_job.rb b/app/jobs/stat_creating_job.rb index 6ca0f9fe..610ad38c 100644 --- a/app/jobs/stat_creating_job.rb +++ b/app/jobs/stat_creating_job.rb @@ -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 diff --git a/app/models/stat.rb b/app/models/stat.rb index 45ec29dd..5250255a 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -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 diff --git a/app/services/countries_and_cities.rb b/app/services/countries_and_cities.rb index fa952448..873a3edd 100644 --- a/app/services/countries_and_cities.rb +++ b/app/services/countries_and_cities.rb @@ -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 diff --git a/app/services/create_stats.rb b/app/services/create_stats.rb index 04125574..b99a4fa3 100644 --- a/app/services/create_stats.rb +++ b/app/services/create_stats.rb @@ -1,32 +1,34 @@ # 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| - 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 + 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 - points = points(beginning_of_month_timestamp, end_of_month_timestamp) - next if points.empty? + points = points(beginning_of_month_timestamp, end_of_month_timestamp) + next if points.empty? - stat = Stat.find_or_initialize_by(year: year, month: month, user: user) - stat.distance = distance(points) - stat.toponyms = toponyms(points) - stat.daily_distance = stat.distance_by_day - stat.save + stat = Stat.find_or_initialize_by(year: year, month: month, user: user) + stat.distance = distance(points) + stat.toponyms = toponyms(points) + stat.daily_distance = stat.distance_by_day + stat.save - stat + stat + end end - end.compact + end end private diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d5b6db57..dbd7a696 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -6,7 +6,7 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> - + <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> diff --git a/app/views/points/index.html.erb b/app/views/points/index.html.erb index 544c5f0a..62637846 100644 --- a/app/views/points/index.html.erb +++ b/app/views/points/index.html.erb @@ -1,4 +1,4 @@ -
+
<%= form_with url: points_path, method: :get do |f| %>
@@ -32,7 +32,7 @@
-
+
<%= render 'shared/right_sidebar' %>
diff --git a/app/views/shared/_right_sidebar.html.erb b/app/views/shared/_right_sidebar.html.erb index 222d7dbf..4c9755ea 100644 --- a/app/views/shared/_right_sidebar.html.erb +++ b/app/views/shared/_right_sidebar.html.erb @@ -1,6 +1,36 @@ <%= "#{@distance} km" if @distance %> +
+ + + <% @years.each do |year| %> +

+ <%= year %> +

+ +
+ <% (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 %> +
<%= Date::ABBR_MONTHNAMES[month_number] %>
+ <% end %> + <% end %> + <% end %> +
+ <% end %> +
+ <% if REVERSE_GEOCODING_ENABLED && @countries_and_cities&.any? %> +
<% @countries_and_cities.each do |country| %>

<%= country[:country] %> (<%= country[:cities].count %> cities) diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index 0c6a4a70..a03d12d5 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -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' %>

-

<%= number_with_delimiter year_distance_stat_in_km(year) %>km

+

+ <% cache [current_user, 'year_distance_stat_in_km', year], skip_digest: true do %> + <%= number_with_delimiter year_distance_stat_in_km(year) %>km + <% end %> +

<% if REVERSE_GEOCODING_ENABLED %>
- <%= countries_and_cities_stat(year) %> + <% cache [current_user, 'countries_and_cities_stat', year], skip_digest: true do %> + <%= countries_and_cities_stat(year) %> + <% end %>
<% end %> <%= column_chart( diff --git a/config/initializers/00_constants.rb b/config/initializers/00_constants.rb index 7c2d3dc6..593675c1 100644 --- a/config/initializers/00_constants.rb +++ b/config/initializers/00_constants.rb @@ -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' diff --git a/config/schedule.yml b/config/schedule.yml new file mode 100644 index 00000000..ecd07f21 --- /dev/null +++ b/config/schedule.yml @@ -0,0 +1,6 @@ +# config/schedule.yml + +stat_creating_job: + cron: "0 */6 * * *" + class: "StatCreatingJob" + queue: default diff --git a/spec/jobs/import_job_spec.rb b/spec/jobs/import_job_spec.rb index ad362007..97acb372 100644 --- a/spec/jobs/import_job_spec.rb +++ b/spec/jobs/import_job_spec.rb @@ -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 diff --git a/spec/jobs/point_creating_job_spec.rb b/spec/jobs/point_creating_job_spec.rb new file mode 100644 index 00000000..c5de9997 --- /dev/null +++ b/spec/jobs/point_creating_job_spec.rb @@ -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 diff --git a/spec/requests/api/v1/points_spec.rb b/spec/requests/api/v1/points_spec.rb index 9782ad70..f7b817d4 100644 --- a/spec/requests/api/v1/points_spec.rb +++ b/spec/requests/api/v1/points_spec.rb @@ -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 - 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\"]}") + it 'enqueues a job' do + expect { + post api_v1_points_path, params: params + }.to have_enqueued_job(PointCreatingJob) end end end diff --git a/spec/services/own_tracks/export_parser_spec.rb b/spec/services/own_tracks/export_parser_spec.rb index 0908f753..d261c762 100644 --- a/spec/services/own_tracks/export_parser_spec.rb +++ b/spec/services/own_tracks/export_parser_spec.rb @@ -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) }