mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Add follow up emails
This commit is contained in:
parent
e3b2fcd415
commit
68a0a8f23c
19 changed files with 614 additions and 90 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [UNRELEASED]
|
||||
|
||||
## Changed
|
||||
|
||||
- Stats page now loads significantly faster due to caching
|
||||
- Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Tracked distance on year card on the Stats page will always be equal to the sum of distances on the monthly chart below it. #466
|
||||
- Stats are now being calculated for trial users as well as active ones.
|
||||
|
||||
# [0.31.0] - 2025-09-04
|
||||
|
||||
The Search release
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -29,58 +29,6 @@ module ApplicationHelper
|
|||
%w[info success warning error accent secondary primary]
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_year(year, stats)
|
||||
data = { countries: [], cities: [] }
|
||||
|
||||
stats.select { _1.year == year }.each do
|
||||
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
|
||||
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
|
||||
end
|
||||
|
||||
data[:cities].flatten!.uniq!
|
||||
data[:countries].flatten!.uniq!
|
||||
|
||||
grouped_by_country = {}
|
||||
stats.select { _1.year == year }.each do |stat|
|
||||
stat.toponyms.flatten.each do |toponym|
|
||||
country = toponym['country']
|
||||
next unless country.present?
|
||||
|
||||
grouped_by_country[country] ||= []
|
||||
|
||||
next unless toponym['cities'].present?
|
||||
|
||||
toponym['cities'].each do |city_data|
|
||||
city = city_data['city']
|
||||
grouped_by_country[country] << city if city.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_by_country.transform_values!(&:uniq)
|
||||
|
||||
{
|
||||
countries_count: data[:countries].count,
|
||||
cities_count: data[:cities].count,
|
||||
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
|
||||
year: year,
|
||||
modal_id: "countries_cities_modal_#{year}"
|
||||
}
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_month(stat)
|
||||
countries = stat.toponyms.count { _1['country'] }
|
||||
cities = stat.toponyms.sum { _1['cities'].count }
|
||||
|
||||
"#{countries} countries, #{cities} cities"
|
||||
end
|
||||
|
||||
def year_distance_stat(year, user)
|
||||
# Distance is now stored in meters, convert to user's preferred unit for display
|
||||
total_distance_meters = Stat.year_distance(year, user).sum { _1[1] }
|
||||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def past?(year, month)
|
||||
DateTime.new(year, month).past?
|
||||
end
|
||||
|
|
|
|||
55
app/helpers/stats_helper.rb
Normal file
55
app/helpers/stats_helper.rb
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module StatsHelper
|
||||
def year_distance_stat(year_data, user)
|
||||
total_distance_meters = year_data.sum { _1[1] }
|
||||
|
||||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_year(year, stats)
|
||||
data = { countries: [], cities: [] }
|
||||
|
||||
stats.select { _1.year == year }.each do
|
||||
data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
|
||||
data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
|
||||
end
|
||||
|
||||
data[:cities].flatten!.uniq!
|
||||
data[:countries].flatten!.uniq!
|
||||
|
||||
grouped_by_country = {}
|
||||
stats.select { _1.year == year }.each do |stat|
|
||||
stat.toponyms.flatten.each do |toponym|
|
||||
country = toponym['country']
|
||||
next if country.blank?
|
||||
|
||||
grouped_by_country[country] ||= []
|
||||
|
||||
next if toponym['cities'].blank?
|
||||
|
||||
toponym['cities'].each do |city_data|
|
||||
city = city_data['city']
|
||||
grouped_by_country[country] << city if city.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_by_country.transform_values!(&:uniq)
|
||||
|
||||
{
|
||||
countries_count: data[:countries].count,
|
||||
cities_count: data[:cities].count,
|
||||
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
|
||||
year: year,
|
||||
modal_id: "countries_cities_modal_#{year}"
|
||||
}
|
||||
end
|
||||
|
||||
def countries_and_cities_stat_for_month(stat)
|
||||
countries = stat.toponyms.count { _1['country'] }
|
||||
cities = stat.toponyms.sum { _1['cities'].count }
|
||||
|
||||
"#{countries} countries, #{cities} cities"
|
||||
end
|
||||
end
|
||||
|
|
@ -4,7 +4,7 @@ class BulkStatsCalculatingJob < ApplicationJob
|
|||
queue_as :stats
|
||||
|
||||
def perform
|
||||
user_ids = User.active.pluck(:id)
|
||||
user_ids = User.active.pluck(:id) + User.trial.pluck(:id)
|
||||
|
||||
user_ids.each do |user_id|
|
||||
Stats::BulkCalculator.new(user_id).call
|
||||
|
|
|
|||
18
app/jobs/cache/preheating_job.rb
vendored
18
app/jobs/cache/preheating_job.rb
vendored
|
|
@ -10,6 +10,24 @@ class Cache::PreheatingJob < ApplicationJob
|
|||
user.years_tracked,
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_points_geocoded_stats",
|
||||
StatsQuery.new(user).send(:cached_points_geocoded_stats),
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_countries_visited",
|
||||
user.send(:countries_visited_uncached),
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
Rails.cache.write(
|
||||
"dawarich/user_#{user.id}_cities_visited",
|
||||
user.send(:cities_visited_uncached),
|
||||
expires_in: 1.day
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
def perform(user_id, email_type, **options)
|
||||
user = User.find(user_id)
|
||||
|
||||
if trial_related_email?(email_type) && user.active?
|
||||
Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed"
|
||||
if should_skip_email?(user, email_type)
|
||||
Rails.logger.info "Skipping #{email_type} email for user #{user_id} - #{skip_reason(user, email_type)}"
|
||||
return
|
||||
end
|
||||
|
||||
|
|
@ -18,7 +18,33 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
|
||||
private
|
||||
|
||||
def trial_related_email?(email_type)
|
||||
%w[trial_expires_soon trial_expired].include?(email_type.to_s)
|
||||
def should_skip_email?(user, email_type)
|
||||
case email_type.to_s
|
||||
when 'trial_expires_soon', 'trial_expired'
|
||||
user.active?
|
||||
when 'post_trial_reminder_early', 'post_trial_reminder_late'
|
||||
user.active? || !user.trial?
|
||||
when 'subscription_expires_soon_early', 'subscription_expires_soon_late'
|
||||
!user.active? || !user.active_until&.future?
|
||||
when 'subscription_expired_early', 'subscription_expired_late'
|
||||
user.active? || user.active_until&.future? || user.trial?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def skip_reason(user, email_type)
|
||||
case email_type.to_s
|
||||
when 'trial_expires_soon', 'trial_expired'
|
||||
'user is already subscribed'
|
||||
when 'post_trial_reminder_early', 'post_trial_reminder_late'
|
||||
user.active? ? 'user is subscribed' : 'user is not in trial state'
|
||||
when 'subscription_expires_soon_early', 'subscription_expires_soon_late'
|
||||
'user is not active or subscription already expired'
|
||||
when 'subscription_expired_early', 'subscription_expired_late'
|
||||
'user is active, subscription not expired, or user is in trial'
|
||||
else
|
||||
'unknown reason'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,4 +24,40 @@ class UsersMailer < ApplicationMailer
|
|||
|
||||
mail(to: @user.email, subject: '💔 Your Dawarich trial expired')
|
||||
end
|
||||
|
||||
def post_trial_reminder_early
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '🚀 Still interested in Dawarich? Subscribe now!')
|
||||
end
|
||||
|
||||
def post_trial_reminder_late
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '📍 Your location data is waiting - Subscribe to Dawarich')
|
||||
end
|
||||
|
||||
def subscription_expires_soon_early
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '⚠️ Your Dawarich subscription expires in 14 days')
|
||||
end
|
||||
|
||||
def subscription_expires_soon_late
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '🚨 Your Dawarich subscription expires in 2 days')
|
||||
end
|
||||
|
||||
def subscription_expired_early
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '💔 Your Dawarich subscription expired - Reactivate now')
|
||||
end
|
||||
|
||||
def subscription_expired_late
|
||||
@user = params[:user]
|
||||
|
||||
mail(to: @user.email, subject: '📍 Missing your location insights? Renew Dawarich subscription')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
after_create :create_api_key
|
||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||
after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? }
|
||||
after_commit :schedule_subscription_emails_on_activation, if: :should_schedule_subscription_emails?
|
||||
|
||||
before_save :sanitize_input
|
||||
|
||||
|
|
@ -35,15 +36,20 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
end
|
||||
|
||||
def countries_visited
|
||||
points
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do
|
||||
points
|
||||
.without_raw_data
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
end
|
||||
end
|
||||
|
||||
def cities_visited
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
end
|
||||
|
||||
def total_distance
|
||||
|
|
@ -151,5 +157,63 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features')
|
||||
Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon')
|
||||
Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired')
|
||||
schedule_post_trial_emails
|
||||
end
|
||||
|
||||
def schedule_post_trial_emails
|
||||
Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early')
|
||||
Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late')
|
||||
end
|
||||
|
||||
def schedule_subscription_expiry_emails
|
||||
return unless active? && active_until&.future?
|
||||
|
||||
days_until_expiry = (active_until.to_date - Time.current.to_date).to_i
|
||||
|
||||
if days_until_expiry >= 14
|
||||
Users::MailerSendingJob.set(wait: (days_until_expiry - 14).days).perform_later(id,
|
||||
'subscription_expires_soon_early')
|
||||
end
|
||||
|
||||
if days_until_expiry >= 2
|
||||
Users::MailerSendingJob.set(wait: (days_until_expiry - 2).days).perform_later(id,
|
||||
'subscription_expires_soon_late')
|
||||
end
|
||||
|
||||
schedule_subscription_expired_emails
|
||||
end
|
||||
|
||||
def schedule_subscription_expired_emails
|
||||
return unless active? && active_until&.future?
|
||||
|
||||
days_until_expiry = (active_until.to_date - Time.current.to_date).to_i
|
||||
|
||||
Users::MailerSendingJob.set(wait: (days_until_expiry + 7).days).perform_later(id, 'subscription_expired_early')
|
||||
Users::MailerSendingJob.set(wait: (days_until_expiry + 14).days).perform_later(id, 'subscription_expired_late')
|
||||
end
|
||||
|
||||
def should_schedule_subscription_emails?
|
||||
return false unless persisted?
|
||||
|
||||
# Schedule if status changed to active or active_until was updated for an active user
|
||||
(saved_change_to_status? && status == 'active') ||
|
||||
(saved_change_to_active_until? && active? && active_until&.future?)
|
||||
end
|
||||
|
||||
def schedule_subscription_emails_on_activation
|
||||
schedule_subscription_expiry_emails
|
||||
end
|
||||
|
||||
def countries_visited_uncached
|
||||
points
|
||||
.without_raw_data
|
||||
.where.not(country_name: [nil, ''])
|
||||
.distinct
|
||||
.pluck(:country_name)
|
||||
.compact
|
||||
end
|
||||
|
||||
def cities_visited_uncached
|
||||
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,10 +6,25 @@ class StatsQuery
|
|||
end
|
||||
|
||||
def points_stats
|
||||
cached_stats = Rails.cache.fetch("dawarich/user_#{user.id}_points_geocoded_stats", expires_in: 1.day) do
|
||||
cached_points_geocoded_stats
|
||||
end
|
||||
|
||||
{
|
||||
total: user.points_count,
|
||||
geocoded: cached_stats[:geocoded],
|
||||
without_data: cached_stats[:without_data]
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user
|
||||
|
||||
def cached_points_geocoded_stats
|
||||
sql = ActiveRecord::Base.sanitize_sql_array([
|
||||
<<~SQL.squish,
|
||||
SELECT
|
||||
COUNT(id) as total,
|
||||
COUNT(reverse_geocoded_at) as geocoded,
|
||||
COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data
|
||||
FROM points
|
||||
|
|
@ -21,13 +36,8 @@ class StatsQuery
|
|||
result = Point.connection.select_one(sql)
|
||||
|
||||
{
|
||||
total: result['total'].to_i,
|
||||
geocoded: result['geocoded'].to_i,
|
||||
without_data: result['without_data'].to_i
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user
|
||||
end
|
||||
|
|
|
|||
15
app/services/cache/clean.rb
vendored
15
app/services/cache/clean.rb
vendored
|
|
@ -7,6 +7,8 @@ class Cache::Clean
|
|||
delete_control_flag
|
||||
delete_version_cache
|
||||
delete_years_tracked_cache
|
||||
delete_points_geocoded_stats_cache
|
||||
delete_countries_cities_cache
|
||||
Rails.logger.info('Cache cleaned')
|
||||
end
|
||||
|
||||
|
|
@ -25,5 +27,18 @@ class Cache::Clean
|
|||
Rails.cache.delete("dawarich/user_#{user.id}_years_tracked")
|
||||
end
|
||||
end
|
||||
|
||||
def delete_points_geocoded_stats_cache
|
||||
User.find_each do |user|
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_points_geocoded_stats")
|
||||
end
|
||||
end
|
||||
|
||||
def delete_countries_cities_cache
|
||||
User.find_each do |user|
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_countries_visited")
|
||||
Rails.cache.delete("dawarich/user_#{user.id}_cities_visited")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<% content_for :title, 'Statistics' %>
|
||||
|
||||
<div class="w-full my-5">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200 relative">
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-primary">
|
||||
<%= number_with_delimiter(current_user.total_distance.round) %> <%= current_user.safe_settings.distance_unit %>
|
||||
|
|
@ -21,6 +21,11 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class='text-xs text-gray-500 text-center mt-5'>
|
||||
All stats data above except for total distance and number of geopoints tracked is being updated daily
|
||||
</div>
|
||||
|
||||
|
||||
<% if current_user.active? %>
|
||||
<%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %>
|
||||
<% end %>
|
||||
|
|
@ -40,9 +45,7 @@
|
|||
</div>
|
||||
</h2>
|
||||
<p>
|
||||
<% cache [current_user, 'year_distance_stat', year], skip_digest: true do %>
|
||||
<%= number_with_delimiter year_distance_stat(year, current_user).round %> <%= current_user.safe_settings.distance_unit %>
|
||||
<% end %>
|
||||
<%= number_with_delimiter year_distance_stat(@year_distances[year], current_user).round %> <%= current_user.safe_settings.distance_unit %>
|
||||
</p>
|
||||
<% if DawarichSettings.reverse_geocoding_enabled? %>
|
||||
<div class="card-actions justify-end">
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@
|
|||
Rails.application.config.after_initialize do
|
||||
# Only run in server mode and ensure one-time execution with atomic write
|
||||
if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true)
|
||||
# Clear the cache
|
||||
Cache::CleaningJob.perform_later
|
||||
|
||||
# Preheat the cache
|
||||
Cache::PreheatingJob.perform_later
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddUserCountryCompositeIndexToPoints < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :points, %i[user_id country_name],
|
||||
algorithm: :concurrently,
|
||||
name: 'idx_points_user_country_name',
|
||||
if_not_exists: true
|
||||
end
|
||||
end
|
||||
3
db/schema.rb
generated
3
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_05_120121) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -205,6 +205,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
|
|||
t.index ["timestamp"], name: "index_points_on_timestamp"
|
||||
t.index ["track_id"], name: "index_points_on_track_id"
|
||||
t.index ["trigger"], name: "index_points_on_trigger"
|
||||
t.index ["user_id", "country_name"], name: "idx_points_user_country_name"
|
||||
t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation"
|
||||
t.index ["user_id"], name: "index_points_on_user_id"
|
||||
t.index ["visit_id"], name: "index_points_on_visit_id"
|
||||
|
|
|
|||
|
|
@ -4,28 +4,153 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe BulkStatsCalculatingJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:user1) { create(:user) }
|
||||
let(:user2) { create(:user) }
|
||||
|
||||
let(:timestamp) { DateTime.new(2024, 1, 1).to_i }
|
||||
|
||||
let!(:points1) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: user1.id, timestamp: timestamp + i.minutes)
|
||||
context 'with active users' do
|
||||
let!(:active_user1) { create(:user, status: :active) }
|
||||
let!(:active_user2) { create(:user, status: :active) }
|
||||
|
||||
let!(:points1) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: active_user1.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:points2) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: active_user2.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
# Remove any leftover users from other tests, keeping only our test users
|
||||
User.where.not(id: [active_user1.id, active_user2.id]).destroy_all
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
||||
it 'processes all active users' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(active_user1.id)
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(active_user2.id)
|
||||
end
|
||||
|
||||
it 'calls Stats::BulkCalculator for each active user' do
|
||||
calculator1 = instance_double(Stats::BulkCalculator)
|
||||
calculator2 = instance_double(Stats::BulkCalculator)
|
||||
|
||||
allow(Stats::BulkCalculator).to receive(:new).with(active_user1.id).and_return(calculator1)
|
||||
allow(Stats::BulkCalculator).to receive(:new).with(active_user2.id).and_return(calculator2)
|
||||
allow(calculator1).to receive(:call)
|
||||
allow(calculator2).to receive(:call)
|
||||
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(calculator1).to have_received(:call)
|
||||
expect(calculator2).to have_received(:call)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:points2) do
|
||||
(1..10).map do |i|
|
||||
create(:point, user_id: user2.id, timestamp: timestamp + i.minutes)
|
||||
context 'with trial users' do
|
||||
let!(:trial_user1) { create(:user, status: :trial) }
|
||||
let!(:trial_user2) { create(:user, status: :trial) }
|
||||
|
||||
let!(:points1) do
|
||||
(1..5).map do |i|
|
||||
create(:point, user_id: trial_user1.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:points2) do
|
||||
(1..5).map do |i|
|
||||
create(:point, user_id: trial_user2.id, timestamp: timestamp + i.minutes)
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
# Remove any leftover users from other tests, keeping only our test users
|
||||
User.where.not(id: [trial_user1.id, trial_user2.id]).destroy_all
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
||||
it 'processes all trial users' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(trial_user1.id)
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(trial_user2.id)
|
||||
end
|
||||
|
||||
it 'calls Stats::BulkCalculator for each trial user' do
|
||||
calculator1 = instance_double(Stats::BulkCalculator)
|
||||
calculator2 = instance_double(Stats::BulkCalculator)
|
||||
|
||||
allow(Stats::BulkCalculator).to receive(:new).with(trial_user1.id).and_return(calculator1)
|
||||
allow(Stats::BulkCalculator).to receive(:new).with(trial_user2.id).and_return(calculator2)
|
||||
allow(calculator1).to receive(:call)
|
||||
allow(calculator2).to receive(:call)
|
||||
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(calculator1).to have_received(:call)
|
||||
expect(calculator2).to have_received(:call)
|
||||
end
|
||||
end
|
||||
|
||||
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)
|
||||
context 'with inactive users only' do
|
||||
before do
|
||||
allow(User).to receive(:active).and_return(User.none)
|
||||
allow(User).to receive(:trial).and_return(User.none)
|
||||
allow(Stats::BulkCalculator).to receive(:new)
|
||||
end
|
||||
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
it 'does not process any users when no active or trial users exist' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'queries for active and trial users but finds none' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(User).to have_received(:active)
|
||||
expect(User).to have_received(:trial)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed user types' do
|
||||
let(:active_user) { create(:user, status: :active) }
|
||||
let(:trial_user) { create(:user, status: :trial) }
|
||||
let(:inactive_user) { create(:user, status: :inactive) }
|
||||
|
||||
before do
|
||||
active_users_relation = double('ActiveRecord::Relation')
|
||||
trial_users_relation = double('ActiveRecord::Relation')
|
||||
|
||||
allow(active_users_relation).to receive(:pluck).with(:id).and_return([active_user.id])
|
||||
allow(trial_users_relation).to receive(:pluck).with(:id).and_return([trial_user.id])
|
||||
|
||||
allow(User).to receive(:active).and_return(active_users_relation)
|
||||
allow(User).to receive(:trial).and_return(trial_users_relation)
|
||||
|
||||
allow(Stats::BulkCalculator).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
|
||||
end
|
||||
|
||||
it 'processes only active and trial users, skipping inactive users' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(active_user.id)
|
||||
expect(Stats::BulkCalculator).to have_received(:new).with(trial_user.id)
|
||||
expect(Stats::BulkCalculator).not_to have_received(:new).with(inactive_user.id)
|
||||
end
|
||||
|
||||
it 'processes exactly 2 users (active and trial)' do
|
||||
BulkStatsCalculatingJob.perform_now
|
||||
|
||||
expect(Stats::BulkCalculator).to have_received(:new).exactly(2).times
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
69
spec/jobs/cache/preheating_job_spec.rb
vendored
Normal file
69
spec/jobs/cache/preheating_job_spec.rb
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Cache::PreheatingJob do
|
||||
before do
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let!(:user1) { create(:user) }
|
||||
let!(:user2) { create(:user) }
|
||||
let!(:import1) { create(:import, user: user1) }
|
||||
let!(:import2) { create(:import, user: user2) }
|
||||
|
||||
before do
|
||||
create_list(:point, 3, user: user1, import: import1, reverse_geocoded_at: Time.current)
|
||||
create_list(:point, 2, user: user2, import: import2, reverse_geocoded_at: Time.current)
|
||||
end
|
||||
|
||||
it 'preheats years_tracked cache for all users' do
|
||||
expect(Rails.cache).to receive(:write).with(
|
||||
"dawarich/user_#{user1.id}_years_tracked",
|
||||
anything,
|
||||
expires_in: 1.day
|
||||
)
|
||||
expect(Rails.cache).to receive(:write).with(
|
||||
"dawarich/user_#{user2.id}_years_tracked",
|
||||
anything,
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
|
||||
it 'preheats points_geocoded_stats cache for all users' do
|
||||
expect(Rails.cache).to receive(:write).with(
|
||||
"dawarich/user_#{user1.id}_points_geocoded_stats",
|
||||
{ geocoded: 3, without_data: 0 },
|
||||
expires_in: 1.day
|
||||
)
|
||||
expect(Rails.cache).to receive(:write).with(
|
||||
"dawarich/user_#{user2.id}_points_geocoded_stats",
|
||||
{ geocoded: 2, without_data: 0 },
|
||||
expires_in: 1.day
|
||||
)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
|
||||
it 'actually writes to cache' do
|
||||
described_class.new.perform
|
||||
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be true
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be true
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be true
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be true
|
||||
end
|
||||
|
||||
it 'handles users with no points gracefully' do
|
||||
user_no_points = create(:user)
|
||||
|
||||
expect { described_class.new.perform }.not_to raise_error
|
||||
|
||||
cached_stats = Rails.cache.read("dawarich/user_#{user_no_points.id}_points_geocoded_stats")
|
||||
expect(cached_stats).to eq({ geocoded: 0, without_data: 0 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe StatsQuery do
|
||||
before do
|
||||
Rails.cache.clear
|
||||
end
|
||||
describe '#points_stats' do
|
||||
subject(:points_stats) { described_class.new(user).points_stats }
|
||||
|
||||
|
|
@ -126,5 +129,46 @@ RSpec.describe StatsQuery do
|
|||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe 'caching behavior' do
|
||||
let!(:points) do
|
||||
create_list(:point, 2,
|
||||
user: user,
|
||||
import: import,
|
||||
reverse_geocoded_at: Time.current,
|
||||
geodata: { 'address' => 'Test Address' })
|
||||
end
|
||||
|
||||
it 'caches the geocoded stats' do
|
||||
expect(Rails.cache).to receive(:fetch).with(
|
||||
"dawarich/user_#{user.id}_points_geocoded_stats",
|
||||
expires_in: 1.day
|
||||
).and_call_original
|
||||
|
||||
points_stats
|
||||
end
|
||||
|
||||
it 'returns cached results on subsequent calls' do
|
||||
# First call - should hit database and cache
|
||||
expect(Point.connection).to receive(:select_one).once.and_call_original
|
||||
first_result = points_stats
|
||||
|
||||
# Second call - should use cache, not hit database
|
||||
expect(Point.connection).not_to receive(:select_one)
|
||||
second_result = points_stats
|
||||
|
||||
expect(first_result).to eq(second_result)
|
||||
end
|
||||
|
||||
it 'uses counter cache for total count' do
|
||||
# Ensure counter cache is set correctly
|
||||
user.reload
|
||||
expect(user.points_count).to eq(2)
|
||||
|
||||
# The total should come from counter cache, not from SQL
|
||||
result = points_stats
|
||||
expect(result[:total]).to eq(user.points_count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
88
spec/services/cache/clean_spec.rb
vendored
Normal file
88
spec/services/cache/clean_spec.rb
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Cache::Clean do
|
||||
before do
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
describe '.call' do
|
||||
let!(:user1) { create(:user) }
|
||||
let!(:user2) { create(:user) }
|
||||
|
||||
before do
|
||||
# Set up cache entries that should be cleaned
|
||||
Rails.cache.write('cache_jobs_scheduled', true)
|
||||
Rails.cache.write(CheckAppVersion::VERSION_CACHE_KEY, '1.0.0')
|
||||
Rails.cache.write("dawarich/user_#{user1.id}_years_tracked", { 2023 => ['Jan', 'Feb'] })
|
||||
Rails.cache.write("dawarich/user_#{user2.id}_years_tracked", { 2023 => ['Mar', 'Apr'] })
|
||||
Rails.cache.write("dawarich/user_#{user1.id}_points_geocoded_stats", { geocoded: 5, without_data: 2 })
|
||||
Rails.cache.write("dawarich/user_#{user2.id}_points_geocoded_stats", { geocoded: 3, without_data: 1 })
|
||||
end
|
||||
|
||||
it 'deletes control flag cache' do
|
||||
expect(Rails.cache.exist?('cache_jobs_scheduled')).to be true
|
||||
|
||||
described_class.call
|
||||
|
||||
expect(Rails.cache.exist?('cache_jobs_scheduled')).to be false
|
||||
end
|
||||
|
||||
it 'deletes version cache' do
|
||||
expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be true
|
||||
|
||||
described_class.call
|
||||
|
||||
expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be false
|
||||
end
|
||||
|
||||
it 'deletes years tracked cache for all users' do
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be true
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be true
|
||||
|
||||
described_class.call
|
||||
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be false
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be false
|
||||
end
|
||||
|
||||
it 'deletes points geocoded stats cache for all users' do
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be true
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be true
|
||||
|
||||
described_class.call
|
||||
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be false
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be false
|
||||
end
|
||||
|
||||
it 'logs cache cleaning process' do
|
||||
expect(Rails.logger).to receive(:info).with('Cleaning cache...')
|
||||
expect(Rails.logger).to receive(:info).with('Cache cleaned')
|
||||
|
||||
described_class.call
|
||||
end
|
||||
|
||||
it 'handles users being added during execution gracefully' do
|
||||
# Create a user that will be found during the cleaning process
|
||||
user3 = nil
|
||||
|
||||
allow(User).to receive(:find_each).and_yield(user1).and_yield(user2) do |&block|
|
||||
# Create a new user while iterating - this should not cause errors
|
||||
user3 = create(:user)
|
||||
Rails.cache.write("dawarich/user_#{user3.id}_years_tracked", { 2023 => ['May'] })
|
||||
Rails.cache.write("dawarich/user_#{user3.id}_points_geocoded_stats", { geocoded: 1, without_data: 0 })
|
||||
|
||||
# Continue with the original block
|
||||
[user1, user2].each(&block)
|
||||
end
|
||||
|
||||
expect { described_class.call }.not_to raise_error
|
||||
|
||||
# The new user's cache should still exist since it wasn't processed
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user3.id}_years_tracked")).to be true
|
||||
expect(Rails.cache.exist?("dawarich/user_#{user3.id}_points_geocoded_stats")).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue