mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Merge remote-tracking branch 'origin/master' into chore/add-telemetry-notification
This commit is contained in:
commit
10c3b14684
27 changed files with 320 additions and 172 deletions
|
|
@ -1 +1 @@
|
||||||
0.19.2
|
0.19.3
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
# 0.19.3 - 2024-12-06
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored stats calculation to calculate only necessary stats, instead of calculating all stats
|
||||||
|
- Stats are now being calculated every 1 hour instead of 6 hours
|
||||||
|
- List of years on the Map page is now being calculated based on user's points instead of stats. It's also being cached for 1 day due to the fact that it's usually a heavy operation based on the number of points.
|
||||||
|
- Reverse-geocoding points is now being performed in batches of 1,000 points to prevent memory exhaustion.
|
||||||
|
|
||||||
# 0.19.2 - 2024-12-04
|
# 0.19.2 - 2024-12-04
|
||||||
|
|
||||||
## The Telemetry release
|
## The Telemetry release
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ class StatsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
Stats::CalculatingJob.perform_later(current_user.id)
|
current_user.years_tracked.each do |year|
|
||||||
|
(1..12).each do |month|
|
||||||
|
Stats::CalculatingJob.perform_later(current_user.id, year, month)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
redirect_to stats_path, notice: 'Stats are being updated', status: :see_other
|
redirect_to stats_path, notice: 'Stats are being updated', status: :see_other
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ class BulkStatsCalculatingJob < ApplicationJob
|
||||||
user_ids = User.pluck(:id)
|
user_ids = User.pluck(:id)
|
||||||
|
|
||||||
user_ids.each do |user_id|
|
user_ids.each do |user_id|
|
||||||
Stats::CalculatingJob.perform_later(user_id)
|
Stats::BulkCalculator.new(user_id).call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
11
app/jobs/cache/preheating_job.rb
vendored
Normal file
11
app/jobs/cache/preheating_job.rb
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Cache::PreheatingJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
User.find_each do |user|
|
||||||
|
Rails.cache.write("dawarich/user_#{user.id}_years_tracked", user.years_tracked, expires_in: 1.day)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
class Stats::CalculatingJob < ApplicationJob
|
class Stats::CalculatingJob < ApplicationJob
|
||||||
queue_as :stats
|
queue_as :stats
|
||||||
|
|
||||||
def perform(user_id, start_at: nil, end_at: nil)
|
def perform(user_id, year, month)
|
||||||
Stats::Calculate.new(user_id, start_at:, end_at:).call
|
Stats::CalculateMonth.new(user_id, year, month).call
|
||||||
|
|
||||||
create_stats_updated_notification(user_id)
|
create_stats_updated_notification(user_id)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,10 @@ class Import < ApplicationRecord
|
||||||
def process!
|
def process!
|
||||||
Imports::Create.new(user, self).call
|
Imports::Create.new(user, self).call
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def years_and_months_tracked
|
||||||
|
points.order(:timestamp).map do |point|
|
||||||
|
[Time.zone.at(point.timestamp).year, Time.zone.at(point.timestamp).month]
|
||||||
|
end.uniq
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,39 +6,16 @@ class Stat < ApplicationRecord
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
def distance_by_day
|
def distance_by_day
|
||||||
timespan.to_a.map.with_index(1) do |day, index|
|
monthly_points = points
|
||||||
beginning_of_day = day.beginning_of_day.to_i
|
calculate_daily_distances(monthly_points)
|
||||||
end_of_day = day.end_of_day.to_i
|
|
||||||
|
|
||||||
# We have to filter by user as well
|
|
||||||
points = user
|
|
||||||
.tracked_points
|
|
||||||
.without_raw_data
|
|
||||||
.order(timestamp: :asc)
|
|
||||||
.where(timestamp: beginning_of_day..end_of_day)
|
|
||||||
|
|
||||||
data = { day: index, distance: 0 }
|
|
||||||
|
|
||||||
points.each_cons(2) do |point1, point2|
|
|
||||||
distance = Geocoder::Calculations.distance_between(
|
|
||||||
point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
|
|
||||||
)
|
|
||||||
|
|
||||||
data[:distance] += distance
|
|
||||||
end
|
|
||||||
|
|
||||||
[data[:day], data[:distance].round(2)]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.year_distance(year, user)
|
def self.year_distance(year, user)
|
||||||
stats = where(year:, user:).order(:month)
|
stats_by_month = where(year:, user:).order(:month).index_by(&:month)
|
||||||
|
|
||||||
(1..12).to_a.map do |month|
|
|
||||||
month_stat = stats.select { |stat| stat.month == month }.first
|
|
||||||
|
|
||||||
|
(1..12).map do |month|
|
||||||
month_name = Date::MONTHNAMES[month]
|
month_name = Date::MONTHNAMES[month]
|
||||||
distance = month_stat&.distance || 0
|
distance = stats_by_month[month]&.distance || 0
|
||||||
|
|
||||||
[month_name, distance]
|
[month_name, distance]
|
||||||
end
|
end
|
||||||
|
|
@ -58,10 +35,11 @@ class Stat < ApplicationRecord
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.years
|
def points
|
||||||
starting_year = select(:year).min&.year || Time.current.year
|
user.tracked_points
|
||||||
|
.without_raw_data
|
||||||
(starting_year..Time.current.year).to_a.reverse
|
.where(timestamp: timespan)
|
||||||
|
.order(timestamp: :asc)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
@ -69,4 +47,25 @@ class Stat < ApplicationRecord
|
||||||
def timespan
|
def timespan
|
||||||
DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
|
DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def calculate_daily_distances(monthly_points)
|
||||||
|
timespan.to_a.map.with_index(1) do |day, index|
|
||||||
|
daily_points = filter_points_for_day(monthly_points, day)
|
||||||
|
distance = calculate_distance(daily_points)
|
||||||
|
[index, distance.round(2)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_points_for_day(points, day)
|
||||||
|
beginning_of_day = day.beginning_of_day.to_i
|
||||||
|
end_of_day = day.end_of_day.to_i
|
||||||
|
|
||||||
|
points.select { |p| p.timestamp.between?(beginning_of_day, end_of_day) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_distance(points)
|
||||||
|
points.each_cons(2).sum do |point1, point2|
|
||||||
|
DistanceCalculator.new(point1, point2).call
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,17 @@ class User < ApplicationRecord
|
||||||
settings['photoprism_url'].present? && settings['photoprism_api_key'].present?
|
settings['photoprism_url'].present? && settings['photoprism_api_key'].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def years_tracked
|
||||||
|
Rails.cache.fetch("dawarich/user_#{id}_years_tracked", expires_in: 1.day) do
|
||||||
|
tracked_points
|
||||||
|
.pluck(:timestamp)
|
||||||
|
.map { |ts| Time.zone.at(ts).year }
|
||||||
|
.uniq
|
||||||
|
.sort
|
||||||
|
.reverse
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def create_api_key
|
def create_api_key
|
||||||
|
|
|
||||||
18
app/services/distance_calculator.rb
Normal file
18
app/services/distance_calculator.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class DistanceCalculator
|
||||||
|
def initialize(point1, point2)
|
||||||
|
@point1 = point1
|
||||||
|
@point2 = point2
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
Geocoder::Calculations.distance_between(
|
||||||
|
point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :point1, :point2
|
||||||
|
end
|
||||||
|
|
@ -34,10 +34,9 @@ class Imports::Create
|
||||||
end
|
end
|
||||||
|
|
||||||
def schedule_stats_creating(user_id)
|
def schedule_stats_creating(user_id)
|
||||||
start_at = import.points.order(:timestamp).first.recorded_at
|
import.years_and_months_tracked.each do |year, month|
|
||||||
end_at = import.points.order(:timestamp).last.recorded_at
|
Stats::CalculatingJob.perform_later(user_id, year, month)
|
||||||
|
end
|
||||||
Stats::CalculatingJob.perform_later(user_id, start_at:, end_at:)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def schedule_visit_suggesting(user_id, import)
|
def schedule_visit_suggesting(user_id, import)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,6 @@ class Jobs::Create
|
||||||
raise InvalidJobName, 'Invalid job name'
|
raise InvalidJobName, 'Invalid job name'
|
||||||
end
|
end
|
||||||
|
|
||||||
points.each(&:async_reverse_geocode)
|
points.find_each(batch_size: 1_000, &:async_reverse_geocode)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
40
app/services/stats/bulk_calculator.rb
Normal file
40
app/services/stats/bulk_calculator.rb
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Stats
|
||||||
|
class BulkCalculator
|
||||||
|
def initialize(user_id)
|
||||||
|
@user_id = user_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
months = extract_months(fetch_timestamps)
|
||||||
|
|
||||||
|
schedule_calculations(months)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user_id
|
||||||
|
|
||||||
|
def fetch_timestamps
|
||||||
|
last_calculated_at = Stat.where(user_id:).maximum(:updated_at)
|
||||||
|
last_calculated_at ||= DateTime.new(1970, 1, 1)
|
||||||
|
|
||||||
|
time_diff = last_calculated_at.to_i..Time.current.to_i
|
||||||
|
Point.where(user_id:, timestamp: time_diff).pluck(:timestamp)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_months(timestamps)
|
||||||
|
timestamps.group_by do |timestamp|
|
||||||
|
time = Time.zone.at(timestamp)
|
||||||
|
[time.year, time.month]
|
||||||
|
end.keys
|
||||||
|
end
|
||||||
|
|
||||||
|
def schedule_calculations(months)
|
||||||
|
months.each do |year, month|
|
||||||
|
Stats::CalculatingJob.perform_later(user_id, year, month)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Stats::Calculate
|
|
||||||
def initialize(user_id, start_at: nil, end_at: nil)
|
|
||||||
@user = User.find(user_id)
|
|
||||||
@start_at = start_at || DateTime.new(1970, 1, 1)
|
|
||||||
@end_at = end_at || Time.current
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
|
||||||
points = points(start_timestamp, end_timestamp)
|
|
||||||
points_by_month = points.group_by_month(&:recorded_at)
|
|
||||||
|
|
||||||
points_by_month.each do |month, month_points|
|
|
||||||
update_month_stats(month_points, month.year, month.month)
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
create_stats_update_failed_notification(user, e)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
attr_reader :user, :start_at, :end_at
|
|
||||||
|
|
||||||
def start_timestamp = start_at.to_i
|
|
||||||
def end_timestamp = end_at.to_i
|
|
||||||
|
|
||||||
def update_month_stats(month_points, year, month)
|
|
||||||
return if month_points.empty?
|
|
||||||
|
|
||||||
stat = current_stat(year, month)
|
|
||||||
distance_by_day = stat.distance_by_day
|
|
||||||
|
|
||||||
stat.daily_distance = distance_by_day
|
|
||||||
stat.distance = distance(distance_by_day)
|
|
||||||
stat.toponyms = toponyms(month_points)
|
|
||||||
stat.save
|
|
||||||
end
|
|
||||||
|
|
||||||
def points(start_at, end_at)
|
|
||||||
user
|
|
||||||
.tracked_points
|
|
||||||
.without_raw_data
|
|
||||||
.where(timestamp: start_at..end_at)
|
|
||||||
.order(:timestamp)
|
|
||||||
.select(:latitude, :longitude, :timestamp, :city, :country)
|
|
||||||
end
|
|
||||||
|
|
||||||
def distance(distance_by_day)
|
|
||||||
distance_by_day.sum { |day| day[1] }
|
|
||||||
end
|
|
||||||
|
|
||||||
def toponyms(points)
|
|
||||||
CountriesAndCities.new(points).call
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_stat(year, month)
|
|
||||||
Stat.find_or_initialize_by(year:, month:, user:)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_stats_update_failed_notification(user, error)
|
|
||||||
Notifications::Create.new(
|
|
||||||
user:,
|
|
||||||
kind: :error,
|
|
||||||
title: 'Stats update failed',
|
|
||||||
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
|
|
||||||
).call
|
|
||||||
end
|
|
||||||
end
|
|
||||||
69
app/services/stats/calculate_month.rb
Normal file
69
app/services/stats/calculate_month.rb
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Stats::CalculateMonth
|
||||||
|
def initialize(user_id, year, month)
|
||||||
|
@user = User.find(user_id)
|
||||||
|
@year = year
|
||||||
|
@month = month
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return if points.empty?
|
||||||
|
|
||||||
|
update_month_stats(year, month)
|
||||||
|
rescue StandardError => e
|
||||||
|
create_stats_update_failed_notification(user, e)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :year, :month
|
||||||
|
|
||||||
|
def start_timestamp = DateTime.new(year, month, 1).to_i
|
||||||
|
|
||||||
|
def end_timestamp
|
||||||
|
DateTime.new(year, month, -1).to_i # -1 returns last day of month
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_month_stats(year, month)
|
||||||
|
Stat.transaction do
|
||||||
|
stat = Stat.find_or_initialize_by(year:, month:, user:)
|
||||||
|
distance_by_day = stat.distance_by_day
|
||||||
|
|
||||||
|
stat.assign_attributes(
|
||||||
|
daily_distance: distance_by_day,
|
||||||
|
distance: distance(distance_by_day),
|
||||||
|
toponyms: toponyms
|
||||||
|
)
|
||||||
|
stat.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def points
|
||||||
|
return @points if defined?(@points)
|
||||||
|
|
||||||
|
@points = user
|
||||||
|
.tracked_points
|
||||||
|
.without_raw_data
|
||||||
|
.where(timestamp: start_timestamp..end_timestamp)
|
||||||
|
.select(:latitude, :longitude, :timestamp, :city, :country)
|
||||||
|
.order(timestamp: :asc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def distance(distance_by_day)
|
||||||
|
distance_by_day.sum { |day| day[1] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def toponyms
|
||||||
|
CountriesAndCities.new(points).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_stats_update_failed_notification(user, error)
|
||||||
|
Notifications::Create.new(
|
||||||
|
user:,
|
||||||
|
kind: :error,
|
||||||
|
title: 'Stats update failed',
|
||||||
|
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<div tabindex="0" role="button" class="btn">Select year</div>
|
<div tabindex="0" role="button" class="btn">Select year</div>
|
||||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||||
<% current_user.stats.years.each do |year| %>
|
<% current_user.years_tracked.each do |year| %>
|
||||||
<li><%= link_to year, map_url(year_timespan(year).merge(year: year, import_id: params[:import_id])) %></li>
|
<li><%= link_to year, map_url(year_timespan(year).merge(year: year, import_id: params[:import_id])) %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# config/schedule.yml
|
# config/schedule.yml
|
||||||
|
|
||||||
bulk_stats_calculating_job:
|
bulk_stats_calculating_job:
|
||||||
cron: "0 */6 * * *" # every 6 hour
|
cron: "0 */1 * * *" # every 1 hour
|
||||||
class: "BulkStatsCalculatingJob"
|
class: "BulkStatsCalculatingJob"
|
||||||
queue: stats
|
queue: stats
|
||||||
|
|
||||||
|
|
@ -30,3 +30,8 @@ telemetry_sending_job:
|
||||||
cron: "0 */1 * * *" # every 1 hour
|
cron: "0 */1 * * *" # every 1 hour
|
||||||
class: "TelemetrySendingJob"
|
class: "TelemetrySendingJob"
|
||||||
queue: default
|
queue: default
|
||||||
|
|
||||||
|
cache_preheating_job:
|
||||||
|
cron: "0 0 * * *" # every day at 0:00
|
||||||
|
class: "Cache::PreheatingJob"
|
||||||
|
queue: default
|
||||||
|
|
|
||||||
9
db/migrate/[timestamp]_add_index_to_points_timestamp.rb
Normal file
9
db/migrate/[timestamp]_add_index_to_points_timestamp.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddIndexToPointsTimestamp < ActiveRecord::Migration[7.2]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
add_index :points, %i[user_id timestamp], algorithm: :concurrently
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,14 +4,17 @@ require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe BulkStatsCalculatingJob, type: :job do
|
RSpec.describe BulkStatsCalculatingJob, type: :job do
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
it 'enqueues Stats::CalculatingJob for each user' do
|
let(:user1) { create(:user) }
|
||||||
user1 = create(:user)
|
let(:user2) { create(:user) }
|
||||||
user2 = create(:user)
|
|
||||||
user3 = create(:user)
|
|
||||||
|
|
||||||
expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id)
|
let(:timestamp) { DateTime.new(2024, 1, 1).to_i }
|
||||||
expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id)
|
|
||||||
expect(Stats::CalculatingJob).to receive(:perform_later).with(user3.id)
|
let!(:points1) { create_list(:point, 10, user_id: user1.id, timestamp:) }
|
||||||
|
let!(:points2) { create_list(:point, 10, user_id: user2.id, timestamp:) }
|
||||||
|
|
||||||
|
it 'enqueues Stats::CalculatingJob for each user' do
|
||||||
|
expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1)
|
||||||
|
expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id, 2024, 1)
|
||||||
|
|
||||||
BulkStatsCalculatingJob.perform_now
|
BulkStatsCalculatingJob.perform_now
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,14 @@ RSpec.describe ImportJob, type: :job do
|
||||||
|
|
||||||
let(:user) { create(:user) }
|
let(:user) { create(:user) }
|
||||||
let!(:import) { create(:import, user:, name: 'owntracks_export.json') }
|
let!(:import) { create(:import, user:, name: 'owntracks_export.json') }
|
||||||
let!(:import_points) { create_list(:point, 9, import: import) }
|
|
||||||
let(:start_at) { Time.zone.at(1_709_283_789) } # Timestamp of the first point in the "2024-03.rec" fixture file
|
|
||||||
let(:end_at) { import.points.reload.order(:timestamp).last.recorded_at }
|
|
||||||
|
|
||||||
it 'creates points' do
|
it 'creates points' do
|
||||||
expect { perform }.to change { Point.count }.by(9)
|
expect { perform }.to change { Point.count }.by(9)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'calls Stats::CalculatingJob' do
|
it 'calls Stats::CalculatingJob' do
|
||||||
expect(Stats::CalculatingJob).to receive(:perform_later).with(user.id, start_at:, end_at:)
|
# Timestamp of the first point in the "2024-03.rec" fixture file
|
||||||
|
expect(Stats::CalculatingJob).to receive(:perform_later).with(user.id, 2024, 3)
|
||||||
|
|
||||||
perform
|
perform
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,36 @@ require 'rails_helper'
|
||||||
RSpec.describe Stats::CalculatingJob, type: :job do
|
RSpec.describe Stats::CalculatingJob, type: :job do
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
let!(:user) { create(:user) }
|
let!(:user) { create(:user) }
|
||||||
let(:start_at) { nil }
|
|
||||||
let(:end_at) { nil }
|
|
||||||
|
|
||||||
subject { described_class.perform_now(user.id) }
|
subject { described_class.perform_now(user.id, 2024, 1) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(Stats::Calculate).to receive(:new).and_call_original
|
allow(Stats::CalculateMonth).to receive(:new).and_call_original
|
||||||
allow_any_instance_of(Stats::Calculate).to receive(:call)
|
allow_any_instance_of(Stats::CalculateMonth).to receive(:call)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'calls Stats::Calculate service' do
|
it 'calls Stats::CalculateMonth service' do
|
||||||
subject
|
subject
|
||||||
|
|
||||||
expect(Stats::Calculate).to have_received(:new).with(user.id, { start_at:, end_at: })
|
expect(Stats::CalculateMonth).to have_received(:new).with(user.id, 2024, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'created notifications' do
|
context 'when Stats::CalculateMonth raises an error' do
|
||||||
|
before do
|
||||||
|
allow_any_instance_of(Stats::CalculateMonth).to receive(:call).and_raise(StandardError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates an error notification' do
|
||||||
expect { subject }.to change { Notification.count }.by(1)
|
expect { subject }.to change { Notification.count }.by(1)
|
||||||
|
expect(Notification.last.kind).to eq('error')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when Stats::CalculateMonth does not raise an error' do
|
||||||
|
it 'creates an info notification' do
|
||||||
|
expect { subject }.to change { Notification.count }.by(1)
|
||||||
|
expect(Notification.last.kind).to eq('info')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,14 @@ RSpec.describe Import, type: :model do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#years_and_months_tracked' do
|
||||||
|
let(:import) { create(:import) }
|
||||||
|
let(:timestamp) { Time.zone.local(2024, 11, 1) }
|
||||||
|
let!(:points) { create_list(:point, 3, import:, timestamp:) }
|
||||||
|
|
||||||
|
it 'returns years and months tracked' do
|
||||||
|
expect(import.years_and_months_tracked).to eq([[2024, 11]])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -51,30 +51,6 @@ RSpec.describe Stat, type: :model do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '.years' do
|
|
||||||
subject { described_class.years }
|
|
||||||
|
|
||||||
context 'when there are no stats' do
|
|
||||||
it 'returns years' do
|
|
||||||
expect(subject).to eq([Time.current.year])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when there are stats' do
|
|
||||||
let(:user) { create(:user) }
|
|
||||||
let(:expected_years) { (year..Time.current.year).to_a.reverse }
|
|
||||||
|
|
||||||
before do
|
|
||||||
create(:stat, year: 2021, user:)
|
|
||||||
create(:stat, year: 2020, user:)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns years' do
|
|
||||||
expect(subject).to eq(expected_years)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#distance_by_day' do
|
describe '#distance_by_day' do
|
||||||
subject { stat.distance_by_day }
|
subject { stat.distance_by_day }
|
||||||
|
|
||||||
|
|
@ -146,5 +122,17 @@ RSpec.describe Stat, type: :model do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#points' do
|
||||||
|
subject { stat.points.to_a }
|
||||||
|
|
||||||
|
let(:stat) { create(:stat, year:, month: 1, user:) }
|
||||||
|
let(:timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) }
|
||||||
|
let!(:points) { create_list(:point, 3, user:, timestamp:) }
|
||||||
|
|
||||||
|
it 'returns points' do
|
||||||
|
expect(subject).to eq(points)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -104,5 +104,15 @@ RSpec.describe User, type: :model do
|
||||||
expect(subject).to eq(1)
|
expect(subject).to eq(1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#years_tracked' do
|
||||||
|
let!(:points) { create_list(:point, 3, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0)) }
|
||||||
|
|
||||||
|
subject { user.years_tracked }
|
||||||
|
|
||||||
|
it 'returns years tracked' do
|
||||||
|
expect(subject).to eq([2024])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,14 @@ RSpec.describe '/stats', type: :request do
|
||||||
describe 'POST /update' do
|
describe 'POST /update' do
|
||||||
let(:stat) { create(:stat, user:, year: 2024) }
|
let(:stat) { create(:stat, user:, year: 2024) }
|
||||||
|
|
||||||
it 'enqueues Stats::CalculatingJob' do
|
it 'enqueues Stats::CalculatingJob for each tracked year and month' do
|
||||||
expect { post stats_url(stat.year) }.to have_enqueued_job(Stats::CalculatingJob)
|
allow(user).to receive(:years_tracked).and_return([2024])
|
||||||
|
|
||||||
|
post stats_url
|
||||||
|
|
||||||
|
(1..12).each do |month|
|
||||||
|
expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, 2024, month)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ RSpec.describe Imports::Create do
|
||||||
let(:import) { create(:import, source: 'google_semantic_history') }
|
let(:import) { create(:import, source: 'google_semantic_history') }
|
||||||
|
|
||||||
it 'calls the GoogleMaps::SemanticHistoryParser' do
|
it 'calls the GoogleMaps::SemanticHistoryParser' do
|
||||||
expect(GoogleMaps::SemanticHistoryParser).to receive(:new).with(import, user.id).and_return(double(call: true))
|
expect(GoogleMaps::SemanticHistoryParser).to \
|
||||||
|
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||||
service.call
|
service.call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -20,7 +21,8 @@ RSpec.describe Imports::Create do
|
||||||
let(:import) { create(:import, source: 'google_phone_takeout') }
|
let(:import) { create(:import, source: 'google_phone_takeout') }
|
||||||
|
|
||||||
it 'calls the GoogleMaps::PhoneTakeoutParser' do
|
it 'calls the GoogleMaps::PhoneTakeoutParser' do
|
||||||
expect(GoogleMaps::PhoneTakeoutParser).to receive(:new).with(import, user.id).and_return(double(call: true))
|
expect(GoogleMaps::PhoneTakeoutParser).to \
|
||||||
|
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||||
service.call
|
service.call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -29,7 +31,8 @@ RSpec.describe Imports::Create do
|
||||||
let(:import) { create(:import, source: 'owntracks') }
|
let(:import) { create(:import, source: 'owntracks') }
|
||||||
|
|
||||||
it 'calls the OwnTracks::ExportParser' do
|
it 'calls the OwnTracks::ExportParser' do
|
||||||
expect(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
|
expect(OwnTracks::ExportParser).to \
|
||||||
|
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||||
service.call
|
service.call
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -42,7 +45,8 @@ RSpec.describe Imports::Create do
|
||||||
|
|
||||||
it 'schedules stats creating' do
|
it 'schedules stats creating' do
|
||||||
Sidekiq::Testing.inline! do
|
Sidekiq::Testing.inline! do
|
||||||
expect { service.call }.to have_enqueued_job(Stats::CalculatingJob)
|
expect { service.call }.to \
|
||||||
|
have_enqueued_job(Stats::CalculatingJob).with(user.id, 2024, 3)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -70,7 +74,8 @@ RSpec.describe Imports::Create do
|
||||||
let(:import) { create(:import, source: 'gpx') }
|
let(:import) { create(:import, source: 'gpx') }
|
||||||
|
|
||||||
it 'calls the Gpx::TrackParser' do
|
it 'calls the Gpx::TrackParser' do
|
||||||
expect(Gpx::TrackParser).to receive(:new).with(import, user.id).and_return(double(call: true))
|
expect(Gpx::TrackParser).to \
|
||||||
|
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||||
service.call
|
service.call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -79,7 +84,8 @@ RSpec.describe Imports::Create do
|
||||||
let(:import) { create(:import, source: 'geojson') }
|
let(:import) { create(:import, source: 'geojson') }
|
||||||
|
|
||||||
it 'calls the Geojson::ImportParser' do
|
it 'calls the Geojson::ImportParser' do
|
||||||
expect(Geojson::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
|
expect(Geojson::ImportParser).to \
|
||||||
|
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||||
service.call
|
service.call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -88,7 +94,8 @@ RSpec.describe Imports::Create do
|
||||||
let(:import) { create(:import, source: 'immich_api') }
|
let(:import) { create(:import, source: 'immich_api') }
|
||||||
|
|
||||||
it 'calls the Photos::ImportParser' do
|
it 'calls the Photos::ImportParser' do
|
||||||
expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
|
expect(Photos::ImportParser).to \
|
||||||
|
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||||
service.call
|
service.call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -97,7 +104,8 @@ RSpec.describe Imports::Create do
|
||||||
let(:import) { create(:import, source: 'photoprism_api') }
|
let(:import) { create(:import, source: 'photoprism_api') }
|
||||||
|
|
||||||
it 'calls the Photos::ImportParser' do
|
it 'calls the Photos::ImportParser' do
|
||||||
expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
|
expect(Photos::ImportParser).to \
|
||||||
|
receive(:new).with(import, user.id).and_return(double(call: true))
|
||||||
service.call
|
service.call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@
|
||||||
|
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Stats::Calculate do
|
RSpec.describe Stats::CalculateMonth do
|
||||||
describe '#call' do
|
describe '#call' do
|
||||||
subject(:calculate_stats) { described_class.new(user.id).call }
|
subject(:calculate_stats) { described_class.new(user.id, year, month).call }
|
||||||
|
|
||||||
let(:user) { create(:user) }
|
let(:user) { create(:user) }
|
||||||
|
let(:year) { 2021 }
|
||||||
|
let(:month) { 1 }
|
||||||
|
|
||||||
context 'when there are no points' do
|
context 'when there are no points' do
|
||||||
it 'does not create stats' do
|
it 'does not create stats' do
|
||||||
|
|
@ -15,9 +17,9 @@ RSpec.describe Stats::Calculate do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when there are points' do
|
context 'when there are points' do
|
||||||
let(:timestamp1) { DateTime.new(2021, 1, 1, 12).to_i }
|
let(:timestamp1) { DateTime.new(year, month, 1, 12).to_i }
|
||||||
let(:timestamp2) { DateTime.new(2021, 1, 1, 13).to_i }
|
let(:timestamp2) { DateTime.new(year, month, 1, 13).to_i }
|
||||||
let(:timestamp3) { DateTime.new(2021, 1, 1, 14).to_i }
|
let(:timestamp3) { DateTime.new(year, month, 1, 14).to_i }
|
||||||
let!(:import) { create(:import, user:) }
|
let!(:import) { create(:import, user:) }
|
||||||
let!(:point1) do
|
let!(:point1) do
|
||||||
create(:point,
|
create(:point,
|
||||||
Loading…
Reference in a new issue